Compare commits

..

3 Commits

Author SHA1 Message Date
Your Name
6c10713e18 v0.2.1 - Nip-40 implemented 2025-09-05 14:45:32 -04:00
Your Name
b810982a17 v0.2.0 - Nip13 implemented 2025-09-05 14:14:15 -04:00
Your Name
23c95fd2ea v0.1.1 - -release 2025-09-05 13:00:42 -04:00
15 changed files with 2625 additions and 109 deletions

View File

@@ -10,20 +10,13 @@ Do NOT modify the formatting, add emojis, or change the text. Keep the simple fo
- [x] NIP-01: Basic protocol flow implementation
- [x] NIP-09: Event deletion
- [ ] NIP-11: Relay information document
- [ ] NIP-12: Generic tag queries
- [ ] NIP-13: Proof of Work
- [x] NIP-11: Relay information document
- [x] NIP-13: Proof of Work
- [x] NIP-15: End of Stored Events Notice
- [ ] NIP-16: Event Treatment
- [x] NIP-20: Command Results
- [ ] NIP-22: Event `created_at` Limits
- [ ] NIP-25: Reactions
- [ ] NIP-26: Delegated Event Signing
- [ ] NIP-28: Public Chat
- [ ] NIP-33: Parameterized Replaceable Events
- [ ] NIP-40: Expiration Timestamp
- [x] NIP-33: Parameterized Replaceable Events
- [x] NIP-40: Expiration Timestamp
- [ ] NIP-42: Authentication of clients to relays
- [ ] NIP-45: Counting results. [experimental](#count)
- [ ] NIP-50: Keywords filter. [experimental](#search)
- [ ] NIP-45: Counting results.
- [ ] NIP-50: Keywords filter.
- [ ] NIP-70: Protected Events

387
admin_spec.md Normal file
View File

@@ -0,0 +1,387 @@
# Ginxsom Admin System - Comprehensive Specification
## Overview
The Ginxsom admin system provides both programmatic (API-based) and interactive (web-based) administration capabilities for the Ginxsom Blossom server. The system is designed around Nostr-based authentication and supports multiple administration workflows including first-run setup, ongoing configuration management, and operational monitoring.
## Architecture Components
### 1. Configuration System
- **File-based configuration**: Signed Nostr events stored as JSON files following XDG Base Directory specification
- **Database configuration**: Key-value pairs stored in SQLite for runtime configuration
- **Interactive setup**: Command-line wizard for initial server configuration
- **Manual setup**: Scripts for generating signed configuration events
### 2. Authentication & Authorization
- **Nostr-based auth**: All admin operations require valid Nostr event signatures
- **Admin pubkey verification**: Only configured admin public keys can perform admin operations
- **Event validation**: Full cryptographic verification of Nostr events including structure, signature, and expiration
- **Method-specific authorization**: Different event types for different operations (upload, admin, delete, etc.)
### 3. API System
- **RESTful endpoints**: `/api/*` routes for programmatic administration
- **Command-line testing**: Complete test suite using `nak` and `curl`
- **JSON responses**: Structured data for all admin operations
- **CORS support**: Cross-origin requests for web admin interface
### 4. Web Interface (Future)
- **Single-page application**: Self-contained HTML file with inline CSS/JS
- **Real-time monitoring**: Statistics and system health dashboards
- **Configuration management**: GUI for server settings
- **File management**: Browse and manage uploaded blobs
## Configuration System Architecture
### File-based Configuration (Priority 1)
**Location**: Follows XDG Base Directory Specification
- `$XDG_CONFIG_HOME/ginxsom/ginxsom_config_event.json`
- Falls back to `$HOME/.config/ginxsom/ginxsom_config_event.json`
**Format**: Signed Nostr event containing server configuration
```json
{
"kind": 33333,
"created_at": 1704067200,
"tags": [
["server_privkey", "server_private_key_hex"],
["cdn_origin", "https://cdn.example.com"],
["max_file_size", "104857600"],
["nip94_enabled", "true"]
],
"content": "Ginxsom server configuration",
"pubkey": "admin_public_key_hex",
"id": "event_id_hash",
"sig": "event_signature"
}
```
**Loading Process**:
1. Check for file-based config at XDG location
2. Validate Nostr event structure and signature
3. Extract configuration from event tags
4. Apply settings to server (database storage)
5. Fall back to database-only config if file missing/invalid
### Database Configuration (Priority 2)
**Table**: `server_config`
```sql
CREATE TABLE server_config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
description TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
```
**Key Configuration Items**:
- `admin_pubkey`: Authorized admin public key
- `admin_enabled`: Enable/disable admin interface
- `cdn_origin`: Base URL for blob access
- `max_file_size`: Maximum upload size in bytes
- `nip94_enabled`: Enable NIP-94 metadata emission
- `auth_rules_enabled`: Enable authentication rules system
### Setup Workflows
#### Interactive Setup (Command Line)
```bash
# First-run detection
if [[ ! -f "$XDG_CONFIG_HOME/ginxsom/ginxsom_config_event.json" ]]; then
echo "=== Ginxsom First-Time Setup Required ==="
echo "1. Run interactive setup wizard"
echo "2. Exit and create config manually"
read -p "Choice (1/2): " choice
if [[ "$choice" == "1" ]]; then
./scripts/setup.sh
else
echo "Manual setup: Run ./scripts/generate_config.sh"
exit 1
fi
fi
```
#### Manual Setup (Script-based)
```bash
# Generate configuration event
./scripts/generate_config.sh --admin-key <admin_pubkey> \
--server-key <server_privkey> \
--cdn-origin "https://cdn.example.com" \
--output "$XDG_CONFIG_HOME/ginxsom/ginxsom_config_event.json"
```
### C Implementation Functions
#### Configuration Loading
```c
// Get XDG-compliant config file path
int get_config_file_path(char* path, size_t path_size);
// Load and validate config event from file
int load_server_config(const char* config_path);
// Extract config from validated event and apply to server
int apply_config_from_event(cJSON* event);
// Interactive setup runner for first-run
int run_interactive_setup(const char* config_path);
```
#### Security Features
- Server private key stored only in memory (never in database)
- Config file must be signed Nostr event
- Full cryptographic validation of config events
- Admin pubkey verification for all operations
## Admin API Specification
### Authentication Model
All admin API endpoints (except `/api/health`) require Nostr authentication:
**Authorization Header Format**:
```
Authorization: Nostr <base64-encoded-event>
```
**Required Event Structure**:
```json
{
"kind": 24242,
"created_at": 1704067200,
"tags": [
["t", "GET"],
["expiration", "1704070800"]
],
"content": "admin_request",
"pubkey": "admin_public_key",
"id": "event_id",
"sig": "event_signature"
}
```
### API Endpoints
#### GET /api/health
**Purpose**: System health check (no authentication required)
**Response**:
```json
{
"status": "success",
"data": {
"database": "connected",
"blob_directory": "accessible",
"server_time": 1704067200,
"uptime": 3600,
"disk_usage": {
"total_bytes": 1073741824,
"used_bytes": 536870912,
"available_bytes": 536870912,
"usage_percent": 50.0
}
}
}
```
#### GET /api/stats
**Purpose**: Server statistics and metrics
**Authentication**: Required (admin pubkey)
**Response**:
```json
{
"status": "success",
"data": {
"total_files": 1234,
"total_bytes": 104857600,
"total_size_mb": 100.0,
"unique_uploaders": 56,
"first_upload": 1693929600,
"last_upload": 1704067200,
"avg_file_size": 85049,
"file_types": {
"image/png": 45,
"image/jpeg": 32,
"application/pdf": 12,
"other": 8
}
}
}
```
#### GET /api/config
**Purpose**: Retrieve current server configuration
**Authentication**: Required (admin pubkey)
**Response**:
```json
{
"status": "success",
"data": {
"cdn_origin": "http://localhost:9001",
"max_file_size": "104857600",
"nip94_enabled": "true",
"auth_rules_enabled": "false",
"auth_cache_ttl": "300"
}
}
```
#### PUT /api/config
**Purpose**: Update server configuration
**Authentication**: Required (admin pubkey)
**Request Body**:
```json
{
"max_file_size": "209715200",
"nip94_enabled": "true",
"cdn_origin": "https://cdn.example.com"
}
```
**Response**:
```json
{
"status": "success",
"message": "Configuration updated successfully",
"updated_keys": ["max_file_size", "cdn_origin"]
}
```
#### GET /api/files
**Purpose**: List recent files with pagination
**Authentication**: Required (admin pubkey)
**Parameters**:
- `limit` (default: 50): Number of files to return
- `offset` (default: 0): Pagination offset
**Response**:
```json
{
"status": "success",
"data": {
"files": [
{
"sha256": "b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553",
"size": 184292,
"type": "application/pdf",
"uploaded_at": 1725105921,
"uploader_pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
"filename": "document.pdf",
"url": "http://localhost:9001/b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553.pdf"
}
],
"total": 1234,
"limit": 50,
"offset": 0
}
}
```
## Implementation Status
### ✅ Completed Components
1. **Database-based configuration loading** - Implemented in main.c
2. **Admin API authentication system** - Implemented in admin_api.c
3. **Nostr event validation** - Full cryptographic verification
4. **Admin pubkey verification** - Database-backed authorization
5. **Basic API endpoints** - Health, stats, config, files
### ✅ Recently Completed Components
1. **File-based configuration system** - Fully implemented in main.c with XDG compliance
2. **Interactive setup wizard** - Complete shell script with guided setup process (`scripts/setup.sh`)
3. **Manual config generation** - Full-featured command-line config generator (`scripts/generate_config.sh`)
4. **Testing infrastructure** - Comprehensive admin API test suite (`scripts/test_admin.sh`)
5. **Documentation system** - Complete setup and usage documentation (`scripts/README.md`)
### 📋 Planned Components
1. **Web admin interface** - Single-page HTML application
2. **Enhanced monitoring** - Real-time statistics dashboard
3. **Bulk operations** - Multi-file management APIs
4. **Configuration validation** - Advanced config checking
5. **Audit logging** - Admin action tracking
## Setup Instructions
### 1. Enable Admin Interface
```bash
# Configure admin pubkey and enable interface
sqlite3 db/ginxsom.db << EOF
INSERT OR REPLACE INTO server_config (key, value, description) VALUES
('admin_pubkey', 'your_admin_public_key_here', 'Authorized admin public key'),
('admin_enabled', 'true', 'Enable admin interface');
EOF
```
### 2. Test API Access
```bash
# Generate admin authentication event
ADMIN_PRIVKEY="your_admin_private_key"
EVENT=$(nak event -k 24242 -c "admin_request" \
--tag t="GET" \
--tag expiration="$(date -d '+1 hour' +%s)" \
--sec "$ADMIN_PRIVKEY")
# Test admin API
AUTH_HEADER="Nostr $(echo "$EVENT" | base64 -w 0)"
curl -H "Authorization: $AUTH_HEADER" http://localhost:9001/api/stats
```
### 3. Configure File-based Setup (Future)
```bash
# Create XDG config directory
mkdir -p "$XDG_CONFIG_HOME/ginxsom"
# Generate signed config event
./scripts/generate_config.sh \
--admin-key "your_admin_pubkey" \
--server-key "generated_server_privkey" \
--output "$XDG_CONFIG_HOME/ginxsom/ginxsom_config_event.json"
```
## Security Considerations
### Authentication Security
- **Event expiration**: All admin events must include expiration timestamps
- **Signature validation**: Full secp256k1 cryptographic verification
- **Replay protection**: Event IDs tracked to prevent reuse
- **Admin key rotation**: Support for updating admin pubkeys
### Configuration Security
- **File permissions**: Config files should be readable only by server user
- **Private key handling**: Server private keys never stored in database
- **Config validation**: All configuration changes validated before application
- **Backup verification**: Config events cryptographically verifiable
### Operational Security
- **Access logging**: All admin operations logged with timestamps
- **Rate limiting**: API endpoints protected against abuse
- **Input validation**: All user input sanitized and validated
- **Database security**: Prepared statements prevent SQL injection
## Future Enhancements
### 1. Web Admin Interface
- Self-contained HTML file with inline CSS/JavaScript
- Real-time monitoring dashboards
- Visual configuration management
- File upload/management interface
### 2. Advanced Monitoring
- Performance metrics collection
- Alert system for critical events
- Historical data trending
- Resource usage tracking
### 3. Multi-admin Support
- Multiple authorized admin pubkeys
- Role-based permissions (read-only vs full admin)
- Admin action audit trails
- Delegation capabilities
### 4. Integration Features
- Nostr relay integration for admin events
- Webhook notifications for admin actions
- External authentication providers
- API key management for programmatic access
This specification represents the current understanding and planned development of the Ginxsom admin system, focusing on security, usability, and maintainability.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

1
otp

Submodule otp deleted from 3d990091eb

126
relay.log
View File

@@ -1,18 +1,35 @@
=== C Nostr Relay Server ===
[SUCCESS] Database connection established
[SUCCESS] Relay information initialized with default values
[INFO] Initializing NIP-13 Proof of Work configuration
[INFO] PoW Configuration: enabled=true, min_difficulty=0, validation_flags=0x1, mode=full
[INFO] Initializing NIP-40 Expiration Timestamp configuration
[INFO] Expiration Configuration: enabled=true, strict_mode=true, filter_responses=true, grace_period=300 seconds
[INFO] Starting relay server...
[INFO] Starting libwebsockets-based Nostr relay server...
[SUCCESS] WebSocket relay started on ws://127.0.0.1:8888
[INFO] HTTP request received
[INFO] Handling NIP-11 relay information request
[SUCCESS] NIP-11 relay information served successfully
[INFO] HTTP request received
[INFO] Handling NIP-11 relay information request
[SUCCESS] NIP-11 relay information served successfully
[INFO] WebSocket connection established
[INFO] Received WebSocket message
[INFO] Handling EVENT message with full NIP-01 validation
[SUCCESS] Event stored in database
[SUCCESS] Event validated and stored successfully
[INFO] WebSocket connection closed
[INFO] WebSocket connection established
[INFO] Received WebSocket message
[INFO] Handling EVENT message with full NIP-01 validation
[SUCCESS] Event stored in database
[SUCCESS] Event validated and stored successfully
[INFO] WebSocket connection closed
[INFO] WebSocket connection established
[INFO] Received WebSocket message
[INFO] Received WebSocket message
[INFO] Received WebSocket message
[INFO] Received WebSocket message
[INFO] Received WebSocket message
[INFO] Received WebSocket message
[INFO] Received WebSocket message
[INFO] Handling EVENT message with full NIP-01 validation
[WARNING] Event rejected: expired timestamp
[INFO] WebSocket connection closed
[INFO] WebSocket connection established
[INFO] Received WebSocket message
@@ -34,98 +51,33 @@
[INFO] WebSocket connection closed
[INFO] WebSocket connection established
[INFO] Received WebSocket message
[INFO] Received WebSocket message
[INFO] Received WebSocket message
[INFO] Received WebSocket message
[WARNING] Subscription 'exists_1757082297' not found for removal
[INFO] Closed subscription: exists_1757082297
[INFO] WebSocket connection closed
[INFO] WebSocket connection established
[INFO] Received WebSocket message
[INFO] Received WebSocket message
[INFO] Received WebSocket message
[INFO] Received WebSocket message
[WARNING] Subscription 'exists_1757082298' not found for removal
[INFO] Closed subscription: exists_1757082298
[INFO] WebSocket connection closed
[INFO] WebSocket connection established
[INFO] Received WebSocket message
[INFO] Handling EVENT message with full NIP-01 validation
[INFO] Event not found for deletion: [INFO] ...
[INFO] Event not found for deletion: [INFO] ...
[SUCCESS] Event stored in database
[INFO] Deletion request processed: 0 events deleted
[INFO] WebSocket connection closed
[INFO] WebSocket connection established
[INFO] Received WebSocket message
[INFO] Received WebSocket message
[INFO] Received WebSocket message
[INFO] Received WebSocket message
[WARNING] Subscription 'exists_1757082301' not found for removal
[INFO] Closed subscription: exists_1757082301
[INFO] WebSocket connection closed
[INFO] WebSocket connection established
[INFO] Received WebSocket message
[INFO] Received WebSocket message
[INFO] Received WebSocket message
[INFO] Received WebSocket message
[WARNING] Subscription 'exists_1757082301' not found for removal
[INFO] Closed subscription: exists_1757082301
[INFO] WebSocket connection closed
[INFO] WebSocket connection established
[INFO] Received WebSocket message
[INFO] Received WebSocket message
[INFO] Received WebSocket message
[INFO] Received WebSocket message
[WARNING] Subscription 'exists_1757082301' not found for removal
[INFO] Closed subscription: exists_1757082301
[INFO] WebSocket connection closed
[INFO] WebSocket connection established
[INFO] Received WebSocket message
[INFO] Handling EVENT message with full NIP-01 validation
[SUCCESS] Event stored in database
[INFO] Deletion request processed: 0 events deleted
[INFO] WebSocket connection closed
[INFO] WebSocket connection established
[INFO] Received WebSocket message
[INFO] Received WebSocket message
[INFO] Received WebSocket message
[INFO] Received WebSocket message
[WARNING] Subscription 'exists_1757082305' not found for removal
[INFO] Closed subscription: exists_1757082305
[INFO] WebSocket connection closed
[INFO] WebSocket connection established
[INFO] Received WebSocket message
[INFO] Handling EVENT message with full NIP-01 validation
[INFO] Event not found for deletion: ✗ Cou...
[SUCCESS] Event stored in database
[INFO] Deletion request processed: 0 events deleted
[WARNING] Event rejected: expired timestamp
[INFO] WebSocket connection closed
[INFO] WebSocket connection established
[INFO] Received WebSocket message
[INFO] Handling REQ message for persistent subscription
[INFO] Added subscription 'exists_1757082309' (total: 1)
[INFO] Executing SQL: SELECT id, pubkey, created_at, kind, content, sig, tags FROM events WHERE 1=1 ORDER BY created_at DESC LIMIT 500
[INFO] Query returned 25 rows
[INFO] Total events sent: 25
[INFO] Added subscription 'filter_test' (total: 1)
[INFO] Executing SQL: SELECT id, pubkey, created_at, kind, content, sig, tags FROM events WHERE 1=1 AND kind IN (1) ORDER BY created_at DESC LIMIT 10
[INFO] Query returned 10 rows
[INFO] Total events sent: 10
[INFO] Received WebSocket message
[INFO] Removed subscription 'exists_1757082309' (total: 0)
[INFO] Closed subscription: exists_1757082309
[INFO] Removed subscription 'filter_test' (total: 0)
[INFO] Closed subscription: filter_test
[INFO] WebSocket connection closed
[WARNING] Subscription 'z[<5B><>.Y' not found for removal
[WARNING] Subscription '<EFBFBD><EFBFBD><15>a' not found for removal
[INFO] WebSocket connection established
[INFO] Received WebSocket message
[INFO] Handling EVENT message with full NIP-01 validation
[SUCCESS] Event stored in database
[SUCCESS] Event validated and stored successfully
[INFO] WebSocket connection closed
[INFO] WebSocket connection established
[INFO] Received WebSocket message
[INFO] Handling REQ message for persistent subscription
[INFO] Added subscription 'kind5_1757082309' (total: 1)
[INFO] Executing SQL: SELECT id, pubkey, created_at, kind, content, sig, tags FROM events WHERE 1=1 AND kind IN (5) ORDER BY created_at DESC LIMIT 500
[INFO] Query returned 3 rows
[INFO] Total events sent: 3
[INFO] Received WebSocket message
[INFO] Removed subscription 'kind5_1757082309' (total: 0)
[INFO] Closed subscription: kind5_1757082309
[INFO] Handling EVENT message with full NIP-01 validation
[SUCCESS] Event stored in database
[SUCCESS] Event validated and stored successfully
[INFO] WebSocket connection closed
[WARNING] Subscription '<27>f<EFBFBD><66>.Y' not found for removal
[INFO] HTTP request received
[INFO] Handling NIP-11 relay information request
[SUCCESS] NIP-11 relay information served successfully

View File

@@ -1 +1 @@
682319
743964

View File

@@ -14,6 +14,7 @@
// Include nostr_core_lib for Nostr functionality
#include "../nostr_core_lib/cjson/cJSON.h"
#include "../nostr_core_lib/nostr_core/nostr_core.h"
#include "../nostr_core_lib/nostr_core/nip013.h" // NIP-13: Proof of Work
// Server Configuration
#define DEFAULT_PORT 8888
@@ -28,6 +29,13 @@
#define SUBSCRIPTION_ID_MAX_LENGTH 64
#define CLIENT_IP_MAX_LENGTH 64
// NIP-11 relay information configuration
#define RELAY_NAME_MAX_LENGTH 128
#define RELAY_DESCRIPTION_MAX_LENGTH 1024
#define RELAY_URL_MAX_LENGTH 256
#define RELAY_CONTACT_MAX_LENGTH 128
#define RELAY_PUBKEY_MAX_LENGTH 65 // 64 hex chars + null terminator
// Color constants for logging
#define RED "\033[31m"
#define GREEN "\033[32m"
@@ -41,6 +49,72 @@ static sqlite3* g_db = NULL;
static int g_server_running = 1;
static struct lws_context *ws_context = NULL;
// NIP-11 relay information structure
struct relay_info {
char name[RELAY_NAME_MAX_LENGTH];
char description[RELAY_DESCRIPTION_MAX_LENGTH];
char banner[RELAY_URL_MAX_LENGTH];
char icon[RELAY_URL_MAX_LENGTH];
char pubkey[RELAY_PUBKEY_MAX_LENGTH];
char contact[RELAY_CONTACT_MAX_LENGTH];
char software[RELAY_URL_MAX_LENGTH];
char version[64];
char privacy_policy[RELAY_URL_MAX_LENGTH];
char terms_of_service[RELAY_URL_MAX_LENGTH];
cJSON* supported_nips; // Array of supported NIP numbers
cJSON* limitation; // Server limitations object
cJSON* retention; // Event retention policies array
cJSON* relay_countries; // Array of country codes
cJSON* language_tags; // Array of language tags
cJSON* tags; // Array of content tags
char posting_policy[RELAY_URL_MAX_LENGTH];
cJSON* fees; // Payment fee structure
char payments_url[RELAY_URL_MAX_LENGTH];
};
// Global relay information instance
static struct relay_info g_relay_info = {0};
// NIP-13 PoW configuration structure
struct pow_config {
int enabled; // 0 = disabled, 1 = enabled
int min_pow_difficulty; // Minimum required difficulty (0 = no requirement)
int validation_flags; // Bitflags for validation options
int require_nonce_tag; // 1 = require nonce tag presence
int reject_lower_targets; // 1 = reject if committed < actual difficulty
int strict_format; // 1 = enforce strict nonce tag format
int anti_spam_mode; // 1 = full anti-spam validation
};
// Global PoW configuration instance
static struct pow_config g_pow_config = {
.enabled = 1, // Enable PoW validation by default
.min_pow_difficulty = 0, // No minimum difficulty by default
.validation_flags = NOSTR_POW_VALIDATE_BASIC,
.require_nonce_tag = 0, // Don't require nonce tags by default
.reject_lower_targets = 0, // Allow lower committed targets by default
.strict_format = 0, // Relaxed format validation by default
.anti_spam_mode = 0 // Basic validation by default
};
// NIP-40 Expiration configuration structure
struct expiration_config {
int enabled; // 0 = disabled, 1 = enabled
int strict_mode; // 1 = reject expired events on submission
int filter_responses; // 1 = filter expired events from responses
int delete_expired; // 1 = delete expired events from DB (future feature)
long grace_period; // Grace period in seconds for clock skew
};
// Global expiration configuration instance
static struct expiration_config g_expiration_config = {
.enabled = 1, // Enable expiration handling by default
.strict_mode = 1, // Reject expired events on submission by default
.filter_responses = 1, // Filter expired events from responses by default
.delete_expired = 0, // Don't delete by default (keep for audit)
.grace_period = 300 // 5 minutes grace period for clock skew
};
/////////////////////////////////////////////////////////////////////////////////////////
/////////////////////////////////////////////////////////////////////////////////////////
@@ -151,6 +225,22 @@ int mark_event_as_deleted(const char* event_id, const char* deletion_event_id, c
// Forward declaration for database functions
int store_event(cJSON* event);
// Forward declarations for NIP-11 relay information handling
void init_relay_info();
void cleanup_relay_info();
cJSON* generate_relay_info_json();
int handle_nip11_http_request(struct lws* wsi, const char* accept_header);
// Forward declarations for NIP-13 PoW validation
void init_pow_config();
int validate_event_pow(cJSON* event, char* error_message, size_t error_size);
// Forward declarations for NIP-40 expiration handling
void init_expiration_config();
long extract_expiration_timestamp(cJSON* tags);
int is_event_expired(cJSON* event, time_t current_time);
int validate_event_expiration(cJSON* event, char* error_message, size_t error_size);
/////////////////////////////////////////////////////////////////////////////////////////
/////////////////////////////////////////////////////////////////////////////////////////
@@ -585,6 +675,19 @@ int broadcast_event_to_subscriptions(cJSON* event) {
return 0;
}
// Check if event is expired and should not be broadcast (NIP-40)
if (g_expiration_config.enabled && g_expiration_config.filter_responses) {
time_t current_time = time(NULL);
if (is_event_expired(event, current_time)) {
char debug_msg[256];
cJSON* event_id_obj = cJSON_GetObjectItem(event, "id");
const char* event_id = event_id_obj ? cJSON_GetStringValue(event_id_obj) : "unknown";
snprintf(debug_msg, sizeof(debug_msg), "Skipping broadcast of expired event: %.16s", event_id);
log_info(debug_msg);
return 0; // Don't broadcast expired events
}
}
int broadcasts = 0;
pthread_mutex_lock(&g_subscription_manager.subscriptions_lock);
@@ -922,8 +1025,11 @@ int handle_deletion_request(cJSON* event, char* error_message, size_t error_size
}
const char* requester_pubkey = cJSON_GetStringValue(pubkey_obj);
// Extract deletion event ID and reason (for potential logging)
const char* deletion_event_id = cJSON_GetStringValue(event_id_obj);
const char* reason = content_obj ? cJSON_GetStringValue(content_obj) : "";
(void)deletion_event_id; // Mark as intentionally unused for now
(void)reason; // Mark as intentionally unused for now
long deletion_timestamp = (long)cJSON_GetNumberValue(created_at_obj);
if (!cJSON_IsArray(tags_obj)) {
@@ -1011,7 +1117,7 @@ int handle_deletion_request(cJSON* event, char* error_message, size_t error_size
snprintf(debug_msg, sizeof(debug_msg), "Deletion request processed: %d events deleted", deleted_count);
log_info(debug_msg);
snprintf(error_message, error_size, ""); // Success
error_message[0] = '\0'; // Success - empty error message
return 0;
}
@@ -1176,6 +1282,658 @@ int mark_event_as_deleted(const char* event_id, const char* deletion_event_id, c
return 0;
}
/////////////////////////////////////////////////////////////////////////////////////////
/////////////////////////////////////////////////////////////////////////////////////////
// NIP-11 RELAY INFORMATION DOCUMENT
/////////////////////////////////////////////////////////////////////////////////////////
/////////////////////////////////////////////////////////////////////////////////////////
// Initialize relay information with default values
void init_relay_info() {
// Set default relay information
strncpy(g_relay_info.name, "C Nostr Relay", sizeof(g_relay_info.name) - 1);
strncpy(g_relay_info.description, "A high-performance Nostr relay implemented in C with SQLite storage", sizeof(g_relay_info.description) - 1);
strncpy(g_relay_info.software, "https://github.com/teknari/c-relay", sizeof(g_relay_info.software) - 1);
strncpy(g_relay_info.version, "0.1.0", sizeof(g_relay_info.version) - 1);
// Initialize supported NIPs array
g_relay_info.supported_nips = cJSON_CreateArray();
if (g_relay_info.supported_nips) {
cJSON_AddItemToArray(g_relay_info.supported_nips, cJSON_CreateNumber(1)); // NIP-01: Basic protocol
cJSON_AddItemToArray(g_relay_info.supported_nips, cJSON_CreateNumber(9)); // NIP-09: Event deletion
cJSON_AddItemToArray(g_relay_info.supported_nips, cJSON_CreateNumber(11)); // NIP-11: Relay information
cJSON_AddItemToArray(g_relay_info.supported_nips, cJSON_CreateNumber(13)); // NIP-13: Proof of Work
cJSON_AddItemToArray(g_relay_info.supported_nips, cJSON_CreateNumber(15)); // NIP-15: EOSE
cJSON_AddItemToArray(g_relay_info.supported_nips, cJSON_CreateNumber(20)); // NIP-20: Command results
cJSON_AddItemToArray(g_relay_info.supported_nips, cJSON_CreateNumber(40)); // NIP-40: Expiration Timestamp
}
// Initialize server limitations
g_relay_info.limitation = cJSON_CreateObject();
if (g_relay_info.limitation) {
cJSON_AddNumberToObject(g_relay_info.limitation, "max_message_length", 16384);
cJSON_AddNumberToObject(g_relay_info.limitation, "max_subscriptions", MAX_SUBSCRIPTIONS_PER_CLIENT);
cJSON_AddNumberToObject(g_relay_info.limitation, "max_limit", 5000);
cJSON_AddNumberToObject(g_relay_info.limitation, "max_subid_length", SUBSCRIPTION_ID_MAX_LENGTH);
cJSON_AddNumberToObject(g_relay_info.limitation, "max_event_tags", 100);
cJSON_AddNumberToObject(g_relay_info.limitation, "max_content_length", 8196);
cJSON_AddNumberToObject(g_relay_info.limitation, "min_pow_difficulty", g_pow_config.min_pow_difficulty);
cJSON_AddBoolToObject(g_relay_info.limitation, "auth_required", cJSON_False);
cJSON_AddBoolToObject(g_relay_info.limitation, "payment_required", cJSON_False);
cJSON_AddBoolToObject(g_relay_info.limitation, "restricted_writes", cJSON_False);
cJSON_AddNumberToObject(g_relay_info.limitation, "created_at_lower_limit", 0);
cJSON_AddNumberToObject(g_relay_info.limitation, "created_at_upper_limit", 2147483647);
cJSON_AddNumberToObject(g_relay_info.limitation, "default_limit", 500);
}
// Initialize empty retention policies (can be configured later)
g_relay_info.retention = cJSON_CreateArray();
// Initialize language tags - set to global for now
g_relay_info.language_tags = cJSON_CreateArray();
if (g_relay_info.language_tags) {
cJSON_AddItemToArray(g_relay_info.language_tags, cJSON_CreateString("*"));
}
// Initialize relay countries - set to global for now
g_relay_info.relay_countries = cJSON_CreateArray();
if (g_relay_info.relay_countries) {
cJSON_AddItemToArray(g_relay_info.relay_countries, cJSON_CreateString("*"));
}
// Initialize content tags as empty array
g_relay_info.tags = cJSON_CreateArray();
// Initialize fees as empty object (no payment required by default)
g_relay_info.fees = cJSON_CreateObject();
log_success("Relay information initialized with default values");
}
// Clean up relay information JSON objects
void cleanup_relay_info() {
if (g_relay_info.supported_nips) {
cJSON_Delete(g_relay_info.supported_nips);
g_relay_info.supported_nips = NULL;
}
if (g_relay_info.limitation) {
cJSON_Delete(g_relay_info.limitation);
g_relay_info.limitation = NULL;
}
if (g_relay_info.retention) {
cJSON_Delete(g_relay_info.retention);
g_relay_info.retention = NULL;
}
if (g_relay_info.language_tags) {
cJSON_Delete(g_relay_info.language_tags);
g_relay_info.language_tags = NULL;
}
if (g_relay_info.relay_countries) {
cJSON_Delete(g_relay_info.relay_countries);
g_relay_info.relay_countries = NULL;
}
if (g_relay_info.tags) {
cJSON_Delete(g_relay_info.tags);
g_relay_info.tags = NULL;
}
if (g_relay_info.fees) {
cJSON_Delete(g_relay_info.fees);
g_relay_info.fees = NULL;
}
}
// Generate NIP-11 compliant JSON document
cJSON* generate_relay_info_json() {
cJSON* info = cJSON_CreateObject();
if (!info) {
log_error("Failed to create relay info JSON object");
return NULL;
}
// Add basic relay information
if (strlen(g_relay_info.name) > 0) {
cJSON_AddStringToObject(info, "name", g_relay_info.name);
}
if (strlen(g_relay_info.description) > 0) {
cJSON_AddStringToObject(info, "description", g_relay_info.description);
}
if (strlen(g_relay_info.banner) > 0) {
cJSON_AddStringToObject(info, "banner", g_relay_info.banner);
}
if (strlen(g_relay_info.icon) > 0) {
cJSON_AddStringToObject(info, "icon", g_relay_info.icon);
}
if (strlen(g_relay_info.pubkey) > 0) {
cJSON_AddStringToObject(info, "pubkey", g_relay_info.pubkey);
}
if (strlen(g_relay_info.contact) > 0) {
cJSON_AddStringToObject(info, "contact", g_relay_info.contact);
}
// Add supported NIPs
if (g_relay_info.supported_nips) {
cJSON_AddItemToObject(info, "supported_nips", cJSON_Duplicate(g_relay_info.supported_nips, 1));
}
// Add software information
if (strlen(g_relay_info.software) > 0) {
cJSON_AddStringToObject(info, "software", g_relay_info.software);
}
if (strlen(g_relay_info.version) > 0) {
cJSON_AddStringToObject(info, "version", g_relay_info.version);
}
// Add policies
if (strlen(g_relay_info.privacy_policy) > 0) {
cJSON_AddStringToObject(info, "privacy_policy", g_relay_info.privacy_policy);
}
if (strlen(g_relay_info.terms_of_service) > 0) {
cJSON_AddStringToObject(info, "terms_of_service", g_relay_info.terms_of_service);
}
if (strlen(g_relay_info.posting_policy) > 0) {
cJSON_AddStringToObject(info, "posting_policy", g_relay_info.posting_policy);
}
// Add server limitations
if (g_relay_info.limitation) {
cJSON_AddItemToObject(info, "limitation", cJSON_Duplicate(g_relay_info.limitation, 1));
}
// Add retention policies if configured
if (g_relay_info.retention && cJSON_GetArraySize(g_relay_info.retention) > 0) {
cJSON_AddItemToObject(info, "retention", cJSON_Duplicate(g_relay_info.retention, 1));
}
// Add geographical and language information
if (g_relay_info.relay_countries) {
cJSON_AddItemToObject(info, "relay_countries", cJSON_Duplicate(g_relay_info.relay_countries, 1));
}
if (g_relay_info.language_tags) {
cJSON_AddItemToObject(info, "language_tags", cJSON_Duplicate(g_relay_info.language_tags, 1));
}
if (g_relay_info.tags && cJSON_GetArraySize(g_relay_info.tags) > 0) {
cJSON_AddItemToObject(info, "tags", cJSON_Duplicate(g_relay_info.tags, 1));
}
// Add payment information if configured
if (strlen(g_relay_info.payments_url) > 0) {
cJSON_AddStringToObject(info, "payments_url", g_relay_info.payments_url);
}
if (g_relay_info.fees && cJSON_GetObjectItem(g_relay_info.fees, "admission")) {
cJSON_AddItemToObject(info, "fees", cJSON_Duplicate(g_relay_info.fees, 1));
}
return info;
}
// Handle NIP-11 HTTP request
int handle_nip11_http_request(struct lws* wsi, const char* accept_header) {
log_info("Handling NIP-11 relay information request");
// Check if client accepts application/nostr+json
int accepts_nostr_json = 0;
if (accept_header) {
if (strstr(accept_header, "application/nostr+json") != NULL) {
accepts_nostr_json = 1;
}
}
if (!accepts_nostr_json) {
log_warning("HTTP request without proper Accept header for NIP-11");
// Return 406 Not Acceptable
unsigned char buf[LWS_PRE + 256];
unsigned char *p = &buf[LWS_PRE];
unsigned char *start = p;
unsigned char *end = &buf[sizeof(buf) - 1];
if (lws_add_http_header_status(wsi, HTTP_STATUS_NOT_ACCEPTABLE, &p, end)) {
return -1;
}
if (lws_add_http_header_by_token(wsi, WSI_TOKEN_HTTP_CONTENT_TYPE, (unsigned char*)"text/plain", 10, &p, end)) {
return -1;
}
if (lws_add_http_header_content_length(wsi, 0, &p, end)) {
return -1;
}
if (lws_finalize_http_header(wsi, &p, end)) {
return -1;
}
lws_write(wsi, start, p - start, LWS_WRITE_HTTP_HEADERS);
return -1; // Close connection
}
// Generate relay information JSON
cJSON* info_json = generate_relay_info_json();
if (!info_json) {
log_error("Failed to generate relay info JSON");
unsigned char buf[LWS_PRE + 256];
unsigned char *p = &buf[LWS_PRE];
unsigned char *start = p;
unsigned char *end = &buf[sizeof(buf) - 1];
if (lws_add_http_header_status(wsi, HTTP_STATUS_INTERNAL_SERVER_ERROR, &p, end)) {
return -1;
}
if (lws_add_http_header_by_token(wsi, WSI_TOKEN_HTTP_CONTENT_TYPE, (unsigned char*)"text/plain", 10, &p, end)) {
return -1;
}
if (lws_add_http_header_content_length(wsi, 0, &p, end)) {
return -1;
}
if (lws_finalize_http_header(wsi, &p, end)) {
return -1;
}
lws_write(wsi, start, p - start, LWS_WRITE_HTTP_HEADERS);
return -1;
}
char* json_string = cJSON_Print(info_json);
cJSON_Delete(info_json);
if (!json_string) {
log_error("Failed to serialize relay info JSON");
unsigned char buf[LWS_PRE + 256];
unsigned char *p = &buf[LWS_PRE];
unsigned char *start = p;
unsigned char *end = &buf[sizeof(buf) - 1];
if (lws_add_http_header_status(wsi, HTTP_STATUS_INTERNAL_SERVER_ERROR, &p, end)) {
return -1;
}
if (lws_add_http_header_by_token(wsi, WSI_TOKEN_HTTP_CONTENT_TYPE, (unsigned char*)"text/plain", 10, &p, end)) {
return -1;
}
if (lws_add_http_header_content_length(wsi, 0, &p, end)) {
return -1;
}
if (lws_finalize_http_header(wsi, &p, end)) {
return -1;
}
lws_write(wsi, start, p - start, LWS_WRITE_HTTP_HEADERS);
return -1;
}
size_t json_len = strlen(json_string);
// Prepare HTTP response with CORS headers
unsigned char buf[LWS_PRE + 1024];
unsigned char *p = &buf[LWS_PRE];
unsigned char *start = p;
unsigned char *end = &buf[sizeof(buf) - 1];
// Add status
if (lws_add_http_header_status(wsi, HTTP_STATUS_OK, &p, end)) {
free(json_string);
return -1;
}
// Add content type
if (lws_add_http_header_by_token(wsi, WSI_TOKEN_HTTP_CONTENT_TYPE,
(unsigned char*)"application/nostr+json", 22, &p, end)) {
free(json_string);
return -1;
}
// Add content length
if (lws_add_http_header_content_length(wsi, json_len, &p, end)) {
free(json_string);
return -1;
}
// Add CORS headers as required by NIP-11
if (lws_add_http_header_by_name(wsi, (unsigned char*)"access-control-allow-origin:",
(unsigned char*)"*", 1, &p, end)) {
free(json_string);
return -1;
}
if (lws_add_http_header_by_name(wsi, (unsigned char*)"access-control-allow-headers:",
(unsigned char*)"content-type, accept", 20, &p, end)) {
free(json_string);
return -1;
}
if (lws_add_http_header_by_name(wsi, (unsigned char*)"access-control-allow-methods:",
(unsigned char*)"GET, OPTIONS", 12, &p, end)) {
free(json_string);
return -1;
}
// Finalize headers
if (lws_finalize_http_header(wsi, &p, end)) {
free(json_string);
return -1;
}
// Write headers
if (lws_write(wsi, start, p - start, LWS_WRITE_HTTP_HEADERS) < 0) {
free(json_string);
return -1;
}
// Write JSON body
unsigned char *json_buf = malloc(LWS_PRE + json_len);
if (!json_buf) {
free(json_string);
return -1;
}
memcpy(json_buf + LWS_PRE, json_string, json_len);
if (lws_write(wsi, json_buf + LWS_PRE, json_len, LWS_WRITE_HTTP) < 0) {
free(json_string);
free(json_buf);
return -1;
}
free(json_buf);
free(json_string);
log_success("NIP-11 relay information served successfully");
return 0;
}
/////////////////////////////////////////////////////////////////////////////////////////
/////////////////////////////////////////////////////////////////////////////////////////
// NIP-13 PROOF OF WORK VALIDATION
/////////////////////////////////////////////////////////////////////////////////////////
/////////////////////////////////////////////////////////////////////////////////////////
// Initialize PoW configuration with environment variables and defaults
void init_pow_config() {
log_info("Initializing NIP-13 Proof of Work configuration");
// Initialize with defaults (already set in struct initialization)
// Check environment variables for configuration
const char* pow_enabled_env = getenv("RELAY_POW_ENABLED");
if (pow_enabled_env) {
g_pow_config.enabled = (strcmp(pow_enabled_env, "1") == 0 ||
strcmp(pow_enabled_env, "true") == 0 ||
strcmp(pow_enabled_env, "yes") == 0);
}
const char* min_diff_env = getenv("RELAY_MIN_POW_DIFFICULTY");
if (min_diff_env) {
int min_diff = atoi(min_diff_env);
if (min_diff >= 0 && min_diff <= 64) { // Reasonable bounds
g_pow_config.min_pow_difficulty = min_diff;
}
}
const char* pow_mode_env = getenv("RELAY_POW_MODE");
if (pow_mode_env) {
if (strcmp(pow_mode_env, "strict") == 0) {
g_pow_config.validation_flags = NOSTR_POW_VALIDATE_ANTI_SPAM | NOSTR_POW_STRICT_FORMAT;
g_pow_config.require_nonce_tag = 1;
g_pow_config.reject_lower_targets = 1;
g_pow_config.strict_format = 1;
g_pow_config.anti_spam_mode = 1;
log_info("PoW configured in strict anti-spam mode");
} else if (strcmp(pow_mode_env, "full") == 0) {
g_pow_config.validation_flags = NOSTR_POW_VALIDATE_FULL;
g_pow_config.require_nonce_tag = 1;
log_info("PoW configured in full validation mode");
} else if (strcmp(pow_mode_env, "basic") == 0) {
g_pow_config.validation_flags = NOSTR_POW_VALIDATE_BASIC;
log_info("PoW configured in basic validation mode");
} else if (strcmp(pow_mode_env, "disabled") == 0) {
g_pow_config.enabled = 0;
log_info("PoW validation disabled via RELAY_POW_MODE");
}
}
// Log final configuration
char config_msg[512];
snprintf(config_msg, sizeof(config_msg),
"PoW Configuration: enabled=%s, min_difficulty=%d, validation_flags=0x%x, mode=%s",
g_pow_config.enabled ? "true" : "false",
g_pow_config.min_pow_difficulty,
g_pow_config.validation_flags,
g_pow_config.anti_spam_mode ? "anti-spam" :
(g_pow_config.validation_flags & NOSTR_POW_VALIDATE_FULL) ? "full" : "basic");
log_info(config_msg);
}
// Validate event Proof of Work according to NIP-13
int validate_event_pow(cJSON* event, char* error_message, size_t error_size) {
if (!g_pow_config.enabled) {
return 0; // PoW validation disabled
}
if (!event) {
snprintf(error_message, error_size, "pow: null event");
return NOSTR_ERROR_INVALID_INPUT;
}
// If min_pow_difficulty is 0, only validate events that have nonce tags
// This allows events without PoW when difficulty requirement is 0
if (g_pow_config.min_pow_difficulty == 0) {
cJSON* tags = cJSON_GetObjectItem(event, "tags");
int has_nonce_tag = 0;
if (tags && cJSON_IsArray(tags)) {
cJSON* tag = NULL;
cJSON_ArrayForEach(tag, tags) {
if (cJSON_IsArray(tag) && cJSON_GetArraySize(tag) >= 2) {
cJSON* tag_name = cJSON_GetArrayItem(tag, 0);
if (cJSON_IsString(tag_name)) {
const char* name = cJSON_GetStringValue(tag_name);
if (name && strcmp(name, "nonce") == 0) {
has_nonce_tag = 1;
break;
}
}
}
}
}
// If no minimum difficulty required and no nonce tag, skip PoW validation
if (!has_nonce_tag) {
return 0; // Accept event without PoW when min_difficulty=0
}
}
// Perform PoW validation using nostr_core_lib
nostr_pow_result_t pow_result;
int validation_result = nostr_validate_pow(event, g_pow_config.min_pow_difficulty,
g_pow_config.validation_flags, &pow_result);
if (validation_result != NOSTR_SUCCESS) {
// Handle specific error cases with appropriate messages
switch (validation_result) {
case NOSTR_ERROR_NIP13_INSUFFICIENT:
snprintf(error_message, error_size,
"pow: insufficient difficulty: %d < %d",
pow_result.actual_difficulty, g_pow_config.min_pow_difficulty);
log_warning("Event rejected: insufficient PoW difficulty");
break;
case NOSTR_ERROR_NIP13_NO_NONCE_TAG:
// This should not happen with min_difficulty=0 after our check above
if (g_pow_config.min_pow_difficulty > 0) {
snprintf(error_message, error_size, "pow: missing required nonce tag");
log_warning("Event rejected: missing nonce tag");
} else {
return 0; // Allow when min_difficulty=0
}
break;
case NOSTR_ERROR_NIP13_INVALID_NONCE_TAG:
snprintf(error_message, error_size, "pow: invalid nonce tag format");
log_warning("Event rejected: invalid nonce tag format");
break;
case NOSTR_ERROR_NIP13_TARGET_MISMATCH:
snprintf(error_message, error_size,
"pow: committed target (%d) lower than minimum (%d)",
pow_result.committed_target, g_pow_config.min_pow_difficulty);
log_warning("Event rejected: committed target too low (anti-spam protection)");
break;
case NOSTR_ERROR_NIP13_CALCULATION:
snprintf(error_message, error_size, "pow: difficulty calculation failed");
log_error("PoW difficulty calculation error");
break;
case NOSTR_ERROR_EVENT_INVALID_ID:
snprintf(error_message, error_size, "pow: invalid event ID format");
log_warning("Event rejected: invalid event ID for PoW calculation");
break;
default:
snprintf(error_message, error_size, "pow: validation failed - %s",
strlen(pow_result.error_detail) > 0 ? pow_result.error_detail : "unknown error");
log_warning("Event rejected: PoW validation failed");
}
return validation_result;
}
// Log successful PoW validation (only if minimum difficulty is required)
if (g_pow_config.min_pow_difficulty > 0 || pow_result.has_nonce_tag) {
char debug_msg[256];
snprintf(debug_msg, sizeof(debug_msg),
"PoW validated: difficulty=%d, target=%d, nonce=%llu%s",
pow_result.actual_difficulty,
pow_result.committed_target,
(unsigned long long)pow_result.nonce_value,
pow_result.has_nonce_tag ? "" : " (no nonce tag)");
log_info(debug_msg);
}
return 0; // Success
}
/////////////////////////////////////////////////////////////////////////////////////////
/////////////////////////////////////////////////////////////////////////////////////////
// NIP-40 EXPIRATION TIMESTAMP HANDLING
/////////////////////////////////////////////////////////////////////////////////////////
/////////////////////////////////////////////////////////////////////////////////////////
// Initialize expiration configuration with environment variables and defaults
void init_expiration_config() {
log_info("Initializing NIP-40 Expiration Timestamp configuration");
// Check environment variables for configuration
const char* exp_enabled_env = getenv("RELAY_EXPIRATION_ENABLED");
if (exp_enabled_env) {
g_expiration_config.enabled = (strcmp(exp_enabled_env, "1") == 0 ||
strcmp(exp_enabled_env, "true") == 0 ||
strcmp(exp_enabled_env, "yes") == 0);
}
const char* exp_strict_env = getenv("RELAY_EXPIRATION_STRICT");
if (exp_strict_env) {
g_expiration_config.strict_mode = (strcmp(exp_strict_env, "1") == 0 ||
strcmp(exp_strict_env, "true") == 0 ||
strcmp(exp_strict_env, "yes") == 0);
}
const char* exp_filter_env = getenv("RELAY_EXPIRATION_FILTER");
if (exp_filter_env) {
g_expiration_config.filter_responses = (strcmp(exp_filter_env, "1") == 0 ||
strcmp(exp_filter_env, "true") == 0 ||
strcmp(exp_filter_env, "yes") == 0);
}
const char* exp_delete_env = getenv("RELAY_EXPIRATION_DELETE");
if (exp_delete_env) {
g_expiration_config.delete_expired = (strcmp(exp_delete_env, "1") == 0 ||
strcmp(exp_delete_env, "true") == 0 ||
strcmp(exp_delete_env, "yes") == 0);
}
const char* exp_grace_env = getenv("RELAY_EXPIRATION_GRACE_PERIOD");
if (exp_grace_env) {
long grace_period = atol(exp_grace_env);
if (grace_period >= 0 && grace_period <= 86400) { // Max 24 hours
g_expiration_config.grace_period = grace_period;
}
}
// Log final configuration
char config_msg[512];
snprintf(config_msg, sizeof(config_msg),
"Expiration Configuration: enabled=%s, strict_mode=%s, filter_responses=%s, grace_period=%ld seconds",
g_expiration_config.enabled ? "true" : "false",
g_expiration_config.strict_mode ? "true" : "false",
g_expiration_config.filter_responses ? "true" : "false",
g_expiration_config.grace_period);
log_info(config_msg);
}
// Extract expiration timestamp from event tags
long extract_expiration_timestamp(cJSON* tags) {
if (!tags || !cJSON_IsArray(tags)) {
return 0; // No expiration
}
cJSON* tag = NULL;
cJSON_ArrayForEach(tag, tags) {
if (cJSON_IsArray(tag) && cJSON_GetArraySize(tag) >= 2) {
cJSON* tag_name = cJSON_GetArrayItem(tag, 0);
cJSON* tag_value = cJSON_GetArrayItem(tag, 1);
if (cJSON_IsString(tag_name) && cJSON_IsString(tag_value)) {
const char* name = cJSON_GetStringValue(tag_name);
const char* value = cJSON_GetStringValue(tag_value);
if (name && value && strcmp(name, "expiration") == 0) {
long expiration_ts = atol(value);
if (expiration_ts > 0) {
return expiration_ts;
}
}
}
}
}
return 0; // No valid expiration tag found
}
// Check if event is currently expired
int is_event_expired(cJSON* event, time_t current_time) {
if (!event) {
return 0; // Invalid event, not expired
}
cJSON* tags = cJSON_GetObjectItem(event, "tags");
long expiration_ts = extract_expiration_timestamp(tags);
if (expiration_ts == 0) {
return 0; // No expiration timestamp, not expired
}
// Check if current time exceeds expiration + grace period
return (current_time > (expiration_ts + g_expiration_config.grace_period));
}
// Validate event expiration according to NIP-40
int validate_event_expiration(cJSON* event, char* error_message, size_t error_size) {
if (!g_expiration_config.enabled) {
return 0; // Expiration validation disabled
}
if (!event) {
snprintf(error_message, error_size, "expiration: null event");
return -1;
}
// Check if event is expired
time_t current_time = time(NULL);
if (is_event_expired(event, current_time)) {
if (g_expiration_config.strict_mode) {
cJSON* tags = cJSON_GetObjectItem(event, "tags");
long expiration_ts = extract_expiration_timestamp(tags);
snprintf(error_message, error_size,
"invalid: event expired (expiration=%ld, current=%ld, grace=%ld)",
expiration_ts, (long)current_time, g_expiration_config.grace_period);
log_warning("Event rejected: expired timestamp");
return -1;
} else {
// In non-strict mode, log but allow expired events
char debug_msg[256];
snprintf(debug_msg, sizeof(debug_msg),
"Accepting expired event (strict_mode disabled)");
log_info(debug_msg);
}
}
return 0; // Success
}
/////////////////////////////////////////////////////////////////////////////////////////
/////////////////////////////////////////////////////////////////////////////////////////
// DATABASE FUNCTIONS
@@ -1584,6 +2342,9 @@ int handle_req_message(const char* sub_id, cJSON* filters, struct lws *wsi, stru
char* sql_ptr = sql + strlen(sql);
int remaining = sizeof(sql) - strlen(sql);
// Note: Expiration filtering will be done at application level
// after retrieving events to ensure compatibility with all SQLite versions
// Handle kinds filter
cJSON* kinds = cJSON_GetObjectItem(filter, "kinds");
if (kinds && cJSON_IsArray(kinds)) {
@@ -1712,6 +2473,16 @@ int handle_req_message(const char* sub_id, cJSON* filters, struct lws *wsi, stru
}
cJSON_AddItemToObject(event, "tags", tags);
// Check expiration filtering (NIP-40) at application level
if (g_expiration_config.enabled && g_expiration_config.filter_responses) {
time_t current_time = time(NULL);
if (is_event_expired(event, current_time)) {
// Skip this expired event
cJSON_Delete(event);
continue;
}
}
// Send EVENT message
cJSON* event_msg = cJSON_CreateArray();
cJSON_AddItemToArray(event_msg, cJSON_CreateString("EVENT"));
@@ -1801,14 +2572,26 @@ int handle_event_message(cJSON* event, char* error_message, size_t error_size) {
return signature_result;
}
// Step 3: Complete event validation (combines structure + signature + additional checks)
// Step 3: Validate Proof of Work (NIP-13) if enabled
int pow_result = validate_event_pow(event, error_message, error_size);
if (pow_result != 0) {
return pow_result; // PoW validation failed, error message already set
}
// Step 4: Validate expiration timestamp (NIP-40) if enabled
int expiration_result = validate_event_expiration(event, error_message, error_size);
if (expiration_result != 0) {
return expiration_result; // Expiration validation failed, error message already set
}
// Step 5: Complete event validation (combines structure + signature + additional checks)
int validation_result = nostr_validate_event(event);
if (validation_result != NOSTR_SUCCESS) {
snprintf(error_message, error_size, "invalid: complete event validation failed");
return validation_result;
}
// Step 4: Check for special event types and handle accordingly
// Step 6: Check for special event types and handle accordingly
cJSON* kind_obj = cJSON_GetObjectItem(event, "kind");
cJSON* pubkey_obj = cJSON_GetObjectItem(event, "pubkey");
cJSON* created_at_obj = cJSON_GetObjectItem(event, "created_at");
@@ -1843,14 +2626,14 @@ int handle_event_message(cJSON* event, char* error_message, size_t error_size) {
}
} else if (event_type == EVENT_TYPE_EPHEMERAL) {
// Ephemeral events should not be stored
snprintf(error_message, error_size, ""); // Success but no storage
error_message[0] = '\0'; // Success but no storage - empty error message
return 0; // Accept but don't store
}
}
// Step 5: Store event in database
// Step 7: Store event in database
if (store_event(event) == 0) {
snprintf(error_message, error_size, ""); // Success
error_message[0] = '\0'; // Success - empty error message
log_success("Event validated and stored successfully");
return 0;
}
@@ -1873,6 +2656,43 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso
struct per_session_data *pss = (struct per_session_data *)user;
switch (reason) {
case LWS_CALLBACK_HTTP:
// Handle NIP-11 relay information requests (HTTP GET to root path)
{
char *requested_uri = (char *)in;
log_info("HTTP request received");
// Check if this is a GET request to the root path
if (strcmp(requested_uri, "/") == 0) {
// Get Accept header
char accept_header[256] = {0};
int header_len = lws_hdr_copy(wsi, accept_header, sizeof(accept_header) - 1, WSI_TOKEN_HTTP_ACCEPT);
if (header_len > 0) {
accept_header[header_len] = '\0';
// Handle NIP-11 request
if (handle_nip11_http_request(wsi, accept_header) == 0) {
return 0; // Successfully handled
}
} else {
log_warning("HTTP request without Accept header");
}
// Return 404 for other requests
lws_return_http_status(wsi, HTTP_STATUS_NOT_FOUND, NULL);
return -1;
}
// Return 404 for non-root paths
lws_return_http_status(wsi, HTTP_STATUS_NOT_FOUND, NULL);
return -1;
}
case LWS_CALLBACK_HTTP_WRITEABLE:
// HTTP response continuation if needed
break;
case LWS_CALLBACK_ESTABLISHED:
log_info("WebSocket connection established");
memset(pss, 0, sizeof(*pss));
@@ -2175,12 +2995,22 @@ int main(int argc, char* argv[]) {
return 1;
}
// Initialize NIP-11 relay information
init_relay_info();
// Initialize NIP-13 PoW configuration
init_pow_config();
// Initialize NIP-40 expiration configuration
init_expiration_config();
log_info("Starting relay server...");
// Start WebSocket Nostr relay server
int result = start_websocket_relay();
// Cleanup
cleanup_relay_info();
nostr_cleanup();
close_database();

432
tests/11_nip_information.sh Executable file
View File

@@ -0,0 +1,432 @@
#!/bin/bash
# NIP-11 Relay Information Document Test
# Tests HTTP endpoint for relay information according to NIP-11 specification
set -e # Exit on any error
# Color constants
RED='\033[31m'
GREEN='\033[32m'
YELLOW='\033[33m'
BLUE='\033[34m'
BOLD='\033[1m'
RESET='\033[0m'
# Test configuration
RELAY_URL="http://127.0.0.1:8888"
RELAY_WS_URL="ws://127.0.0.1:8888"
# Print functions
print_header() {
echo -e "${BLUE}${BOLD}=== $1 ===${RESET}"
}
print_step() {
echo -e "${YELLOW}[STEP]${RESET} $1"
}
print_success() {
echo -e "${GREEN}${RESET} $1"
}
print_error() {
echo -e "${RED}${RESET} $1"
}
print_info() {
echo -e "${BLUE}[INFO]${RESET} $1"
}
print_warning() {
echo -e "${YELLOW}[WARNING]${RESET} $1"
}
# Test functions
test_http_with_correct_header() {
print_step "Testing HTTP request with correct Accept header"
local response=""
local http_code=""
if command -v curl &> /dev/null; then
# Use curl to test with proper Accept header
response=$(curl -s -H "Accept: application/nostr+json" "$RELAY_URL/" 2>/dev/null || echo "")
http_code=$(curl -s -o /dev/null -w "%{http_code}" -H "Accept: application/nostr+json" "$RELAY_URL/" 2>/dev/null || echo "000")
else
print_error "curl command not found - required for NIP-11 testing"
return 1
fi
if [[ "$http_code" == "200" ]]; then
print_success "HTTP 200 OK received with correct Accept header"
# Validate JSON response
if echo "$response" | jq . >/dev/null 2>&1; then
print_success "Response is valid JSON"
return 0
else
print_error "Response is not valid JSON"
return 1
fi
else
print_error "Expected HTTP 200, got HTTP $http_code"
return 1
fi
}
test_http_without_header() {
print_step "Testing HTTP request without Accept header (should return 406)"
local http_code=""
if command -v curl &> /dev/null; then
http_code=$(curl -s -o /dev/null -w "%{http_code}" "$RELAY_URL/" 2>/dev/null || echo "000")
else
print_error "curl command not found - required for NIP-11 testing"
return 1
fi
if [[ "$http_code" == "406" ]]; then
print_success "HTTP 406 Not Acceptable received without proper Accept header"
return 0
else
print_error "Expected HTTP 406, got HTTP $http_code"
return 1
fi
}
test_http_with_wrong_header() {
print_step "Testing HTTP request with wrong Accept header (should return 406)"
local http_code=""
if command -v curl &> /dev/null; then
http_code=$(curl -s -o /dev/null -w "%{http_code}" -H "Accept: application/json" "$RELAY_URL/" 2>/dev/null || echo "000")
else
print_error "curl command not found - required for NIP-11 testing"
return 1
fi
if [[ "$http_code" == "406" ]]; then
print_success "HTTP 406 Not Acceptable received with wrong Accept header"
return 0
else
print_error "Expected HTTP 406, got HTTP $http_code"
return 1
fi
}
test_cors_headers() {
print_step "Testing CORS headers presence"
local headers=""
if command -v curl &> /dev/null; then
headers=$(curl -s -I -H "Accept: application/nostr+json" "$RELAY_URL/" 2>/dev/null || echo "")
else
print_error "curl command not found - required for NIP-11 testing"
return 1
fi
local cors_origin_found=false
local cors_headers_found=false
local cors_methods_found=false
if echo "$headers" | grep -qi "access-control-allow-origin"; then
cors_origin_found=true
print_success "Access-Control-Allow-Origin header found"
fi
if echo "$headers" | grep -qi "access-control-allow-headers"; then
cors_headers_found=true
print_success "Access-Control-Allow-Headers header found"
fi
if echo "$headers" | grep -qi "access-control-allow-methods"; then
cors_methods_found=true
print_success "Access-Control-Allow-Methods header found"
fi
if [[ "$cors_origin_found" == true && "$cors_headers_found" == true && "$cors_methods_found" == true ]]; then
print_success "All required CORS headers present"
return 0
else
print_error "Missing CORS headers"
return 1
fi
}
test_json_structure() {
print_step "Testing NIP-11 JSON structure and required fields"
local response=""
if command -v curl &> /dev/null; then
response=$(curl -s -H "Accept: application/nostr+json" "$RELAY_URL/" 2>/dev/null || echo "")
else
print_error "curl command not found - required for NIP-11 testing"
return 1
fi
if [[ -z "$response" ]]; then
print_error "Empty response received"
return 1
fi
# Validate JSON structure using jq
if ! echo "$response" | jq . >/dev/null 2>&1; then
print_error "Response is not valid JSON"
return 1
fi
print_success "Valid JSON structure confirmed"
# Check for required fields
local required_checks=0
local total_checks=0
# Test name field
((total_checks++))
if echo "$response" | jq -e '.name' >/dev/null 2>&1; then
local name=$(echo "$response" | jq -r '.name')
print_success "Name field present: $name"
((required_checks++))
else
print_warning "Name field missing (optional)"
fi
# Test supported_nips field (required)
((total_checks++))
if echo "$response" | jq -e '.supported_nips' >/dev/null 2>&1; then
local nips=$(echo "$response" | jq -r '.supported_nips | @json')
print_success "Supported NIPs field present: $nips"
((required_checks++))
# Verify NIP-11 is in the supported list
if echo "$response" | jq -e '.supported_nips | contains([11])' >/dev/null 2>&1; then
print_success "NIP-11 correctly listed in supported NIPs"
else
print_warning "NIP-11 not found in supported NIPs list"
fi
else
print_error "Supported NIPs field missing (should be present)"
fi
# Test software field
((total_checks++))
if echo "$response" | jq -e '.software' >/dev/null 2>&1; then
local software=$(echo "$response" | jq -r '.software')
print_success "Software field present: $software"
((required_checks++))
else
print_warning "Software field missing (optional)"
fi
# Test version field
((total_checks++))
if echo "$response" | jq -e '.version' >/dev/null 2>&1; then
local version=$(echo "$response" | jq -r '.version')
print_success "Version field present: $version"
((required_checks++))
else
print_warning "Version field missing (optional)"
fi
# Test limitation object
((total_checks++))
if echo "$response" | jq -e '.limitation' >/dev/null 2>&1; then
print_success "Limitation object present"
((required_checks++))
# Check some common limitation fields
if echo "$response" | jq -e '.limitation.max_message_length' >/dev/null 2>&1; then
local max_msg=$(echo "$response" | jq -r '.limitation.max_message_length')
print_info " max_message_length: $max_msg"
fi
if echo "$response" | jq -e '.limitation.max_subscriptions' >/dev/null 2>&1; then
local max_subs=$(echo "$response" | jq -r '.limitation.max_subscriptions')
print_info " max_subscriptions: $max_subs"
fi
else
print_warning "Limitation object missing (recommended)"
fi
# Test description field
if echo "$response" | jq -e '.description' >/dev/null 2>&1; then
local description=$(echo "$response" | jq -r '.description')
print_success "Description field present: ${description:0:50}..."
else
print_warning "Description field missing (optional)"
fi
print_info "JSON structure validation: $required_checks/$total_checks core fields present"
return 0
}
test_content_type_header() {
print_step "Testing Content-Type header"
local headers=""
if command -v curl &> /dev/null; then
headers=$(curl -s -I -H "Accept: application/nostr+json" "$RELAY_URL/" 2>/dev/null || echo "")
else
print_error "curl command not found - required for NIP-11 testing"
return 1
fi
if echo "$headers" | grep -qi "content-type.*application/nostr+json"; then
print_success "Correct Content-Type header: application/nostr+json"
return 0
else
print_warning "Content-Type header not exactly 'application/nostr+json'"
echo "$headers" | grep -i "content-type" | head -1
return 1
fi
}
test_non_root_path() {
print_step "Testing non-root path (should return 404)"
local http_code=""
if command -v curl &> /dev/null; then
http_code=$(curl -s -o /dev/null -w "%{http_code}" -H "Accept: application/nostr+json" "$RELAY_URL/nonexistent" 2>/dev/null || echo "000")
else
print_error "curl command not found - required for NIP-11 testing"
return 1
fi
if [[ "$http_code" == "404" ]]; then
print_success "HTTP 404 Not Found received for non-root path"
return 0
else
print_error "Expected HTTP 404 for non-root path, got HTTP $http_code"
return 1
fi
}
test_websocket_still_works() {
print_step "Testing that WebSocket functionality still works on same port"
if ! command -v websocat &> /dev/null; then
print_warning "websocat not available - skipping WebSocket test"
return 0
fi
# Try to connect to WebSocket and send a simple REQ
local response=""
response=$(echo '["REQ","test_ws_nip11",{}]' | timeout 3s websocat "$RELAY_WS_URL" 2>/dev/null || echo "Connection failed")
if [[ "$response" == *"Connection failed"* ]]; then
print_error "WebSocket connection failed"
return 1
elif [[ "$response" == *"EOSE"* ]]; then
print_success "WebSocket still functional - received EOSE response"
return 0
else
print_warning "WebSocket response unclear, but connection succeeded"
return 0
fi
}
# Main test function
run_nip11_tests() {
print_header "NIP-11 Relay Information Document Tests"
# Check dependencies
print_step "Checking dependencies..."
if ! command -v curl &> /dev/null; then
print_error "curl command not found - required for NIP-11 HTTP testing"
return 1
fi
if ! command -v jq &> /dev/null; then
print_error "jq command not found - required for JSON validation"
return 1
fi
print_success "All dependencies found"
print_header "PHASE 1: Basic HTTP Functionality"
# Test 1: Correct Accept header
if ! test_http_with_correct_header; then
return 1
fi
# Test 2: Missing Accept header
if ! test_http_without_header; then
return 1
fi
# Test 3: Wrong Accept header
if ! test_http_with_wrong_header; then
return 1
fi
print_header "PHASE 2: HTTP Headers Validation"
# Test 4: CORS headers
if ! test_cors_headers; then
return 1
fi
# Test 5: Content-Type header
if ! test_content_type_header; then
return 1
fi
print_header "PHASE 3: JSON Structure Validation"
# Test 6: JSON structure and required fields
if ! test_json_structure; then
return 1
fi
print_header "PHASE 4: Additional Endpoint Behavior"
# Test 7: Non-root paths
if ! test_non_root_path; then
return 1
fi
# Test 8: WebSocket compatibility
if ! test_websocket_still_works; then
return 1
fi
print_header "PHASE 5: NIP-11 Compliance Summary"
# Final validation - get the actual response and display it
print_step "Displaying complete NIP-11 response..."
local response=""
if command -v curl &> /dev/null; then
response=$(curl -s -H "Accept: application/nostr+json" "$RELAY_URL/" 2>/dev/null || echo "")
if [[ -n "$response" ]] && echo "$response" | jq . >/dev/null 2>&1; then
echo "$response" | jq .
else
print_error "Failed to retrieve or parse final response"
fi
fi
print_success "All NIP-11 tests passed!"
return 0
}
# Main execution
print_header "Starting NIP-11 Relay Information Document Test Suite"
echo
if run_nip11_tests; then
echo
print_success "All NIP-11 tests completed successfully!"
print_info "The C-Relay NIP-11 implementation is fully compliant"
print_info "✅ HTTP endpoint, Accept header validation, CORS, and JSON structure all working"
echo
exit 0
else
echo
print_error "Some NIP-11 tests failed"
exit 1
fi

384
tests/13_nip_test.sh Executable file
View File

@@ -0,0 +1,384 @@
#!/bin/bash
# NIP-13 Proof of Work Validation Test Suite for C Nostr Relay
# Tests PoW validation in the relay's event processing pipeline
# Based on nostr_core_lib/tests/nip13_test.c
set -e # Exit on error
# Color constants
RED='\033[31m'
GREEN='\033[32m'
YELLOW='\033[33m'
BLUE='\033[34m'
BOLD='\033[1m'
RESET='\033[0m'
# Test configuration
RELAY_URL="ws://127.0.0.1:8888"
HTTP_URL="http://127.0.0.1:8888"
TEST_COUNT=0
PASSED_COUNT=0
FAILED_COUNT=0
# Test results tracking
declare -a TEST_RESULTS=()
print_info() {
echo -e "${BLUE}[INFO]${RESET} $1"
}
print_success() {
echo -e "${GREEN}${BOLD}[SUCCESS]${RESET} $1"
}
print_warning() {
echo -e "${YELLOW}[WARNING]${RESET} $1"
}
print_error() {
echo -e "${RED}${BOLD}[ERROR]${RESET} $1"
}
print_test_header() {
TEST_COUNT=$((TEST_COUNT + 1))
echo ""
echo -e "${BOLD}=== TEST $TEST_COUNT: $1 ===${RESET}"
}
record_test_result() {
local test_name="$1"
local result="$2"
local details="$3"
TEST_RESULTS+=("$test_name|$result|$details")
if [ "$result" = "PASS" ]; then
PASSED_COUNT=$((PASSED_COUNT + 1))
print_success "PASS: $test_name"
else
FAILED_COUNT=$((FAILED_COUNT + 1))
print_error "FAIL: $test_name"
if [ -n "$details" ]; then
echo " Details: $details"
fi
fi
}
# Check if relay is running
check_relay_running() {
print_info "Checking if relay is running..."
if ! curl -s -H "Accept: application/nostr+json" "$HTTP_URL/" >/dev/null 2>&1; then
print_error "Relay is not running or not accessible at $HTTP_URL"
print_info "Please start the relay with: ./make_and_restart_relay.sh"
exit 1
fi
print_success "Relay is running and accessible"
}
# Test NIP-11 relay information includes NIP-13
test_nip11_pow_support() {
print_test_header "NIP-11 PoW Support Advertisement"
print_info "Fetching relay information..."
RELAY_INFO=$(curl -s -H "Accept: application/nostr+json" "$HTTP_URL/")
echo "Relay Info Response:"
echo "$RELAY_INFO" | jq '.'
echo ""
# Check if NIP-13 is in supported_nips
if echo "$RELAY_INFO" | jq -e '.supported_nips | index(13)' >/dev/null 2>&1; then
print_success "✓ NIP-13 found in supported_nips array"
NIP13_SUPPORTED=true
else
print_error "✗ NIP-13 not found in supported_nips array"
NIP13_SUPPORTED=false
fi
# Check if min_pow_difficulty is present
MIN_POW_DIFF=$(echo "$RELAY_INFO" | jq -r '.limitation.min_pow_difficulty // "missing"')
if [ "$MIN_POW_DIFF" != "missing" ]; then
print_success "✓ min_pow_difficulty found: $MIN_POW_DIFF"
MIN_POW_PRESENT=true
else
print_error "✗ min_pow_difficulty not found in limitations"
MIN_POW_PRESENT=false
fi
if [ "$NIP13_SUPPORTED" = true ] && [ "$MIN_POW_PRESENT" = true ]; then
record_test_result "NIP-11 PoW Support Advertisement" "PASS" "NIP-13 supported, min_pow_difficulty=$MIN_POW_DIFF"
return 0
else
record_test_result "NIP-11 PoW Support Advertisement" "FAIL" "Missing NIP-13 support or min_pow_difficulty"
return 1
fi
}
# Test event submission without PoW (should be accepted when min_difficulty=0)
test_event_without_pow() {
print_test_header "Event Submission Without PoW (min_difficulty=0)"
# Create a simple event without PoW
print_info "Generating test event without PoW..."
# Use nak to generate a simple event
if ! command -v nak &> /dev/null; then
print_warning "nak command not found - skipping PoW generation tests"
record_test_result "Event Submission Without PoW" "SKIP" "nak not available"
return 0
fi
# Generate event without PoW using direct private key
PRIVATE_KEY="91ba716fa9e7ea2fcbad360cf4f8e0d312f73984da63d90f524ad61a6a1e7dbe"
EVENT_JSON=$(nak event --sec "$PRIVATE_KEY" -c "Test event without PoW" --ts $(date +%s))
print_info "Generated event:"
echo "$EVENT_JSON" | jq '.'
echo ""
# Send event to relay via WebSocket using websocat
print_info "Sending event to relay..."
# Create EVENT message in Nostr format
EVENT_MESSAGE="[\"EVENT\",$EVENT_JSON]"
# Send to relay and capture response
if command -v websocat &> /dev/null; then
RESPONSE=$(echo "$EVENT_MESSAGE" | timeout 5s websocat "$RELAY_URL" 2>&1 || echo "Connection failed")
print_info "Relay response: $RESPONSE"
if [[ "$RESPONSE" == *"Connection failed"* ]]; then
print_error "✗ Failed to connect to relay"
record_test_result "Event Submission Without PoW" "FAIL" "Connection failed"
return 1
elif [[ "$RESPONSE" == *"true"* ]]; then
print_success "✓ Event without PoW accepted (expected when min_difficulty=0)"
record_test_result "Event Submission Without PoW" "PASS" "Event accepted as expected"
return 0
else
print_error "✗ Event without PoW rejected (unexpected when min_difficulty=0)"
record_test_result "Event Submission Without PoW" "FAIL" "Event rejected: $RESPONSE"
return 1
fi
else
print_error "websocat not found - required for testing"
record_test_result "Event Submission Without PoW" "SKIP" "websocat not available"
return 0
fi
}
# Test event with valid PoW
test_event_with_pow() {
print_test_header "Event Submission With Valid PoW"
if ! command -v nak &> /dev/null; then
print_warning "nak command not found - skipping PoW validation tests"
record_test_result "Event Submission With Valid PoW" "SKIP" "nak not available"
return 0
fi
print_info "Generating event with PoW difficulty 8..."
# Generate event with PoW (difficulty 8 for reasonable test time) using direct private key
PRIVATE_KEY="91ba716fa9e7ea2fcbad360cf4f8e0d312f73984da63d90f524ad61a6a1e7dbe"
POW_EVENT_JSON=$(nak event --sec "$PRIVATE_KEY" -c "Test event with PoW difficulty 8" --pow 8 --ts $(date +%s))
if [ -z "$POW_EVENT_JSON" ]; then
print_error "Failed to generate PoW event"
record_test_result "Event Submission With Valid PoW" "FAIL" "PoW event generation failed"
return 1
fi
print_info "Generated PoW event:"
echo "$POW_EVENT_JSON" | jq '.'
echo ""
# Extract nonce info for verification
NONCE_TAG=$(echo "$POW_EVENT_JSON" | jq -r '.tags[] | select(.[0] == "nonce") | .[1]' 2>/dev/null || echo "")
TARGET_DIFF=$(echo "$POW_EVENT_JSON" | jq -r '.tags[] | select(.[0] == "nonce") | .[2]' 2>/dev/null || echo "")
if [ -n "$NONCE_TAG" ] && [ -n "$TARGET_DIFF" ]; then
print_info "PoW details: nonce=$NONCE_TAG, target_difficulty=$TARGET_DIFF"
fi
# Send event to relay via WebSocket using websocat
print_info "Sending PoW event to relay..."
# Create EVENT message in Nostr format
POW_EVENT_MESSAGE="[\"EVENT\",$POW_EVENT_JSON]"
# Send to relay and capture response
if command -v websocat &> /dev/null; then
RESPONSE=$(echo "$POW_EVENT_MESSAGE" | timeout 10s websocat "$RELAY_URL" 2>&1 || echo "Connection failed")
print_info "Relay response: $RESPONSE"
if [[ "$RESPONSE" == *"Connection failed"* ]]; then
print_error "✗ Failed to connect to relay"
record_test_result "Event Submission With Valid PoW" "FAIL" "Connection failed"
return 1
elif [[ "$RESPONSE" == *"true"* ]]; then
print_success "✓ Event with valid PoW accepted"
record_test_result "Event Submission With Valid PoW" "PASS" "PoW event accepted"
return 0
else
print_error "✗ Event with valid PoW rejected"
record_test_result "Event Submission With Valid PoW" "FAIL" "PoW event rejected: $RESPONSE"
return 1
fi
else
print_error "websocat not found - required for testing"
record_test_result "Event Submission With Valid PoW" "SKIP" "websocat not available"
return 0
fi
}
# Test relay configuration with environment variables
test_pow_configuration() {
print_test_header "PoW Configuration Via Environment Variables"
print_info "Testing different PoW configurations requires relay restart"
print_info "Current configuration from logs:"
if [ -f "relay.log" ]; then
grep "PoW Configuration:" relay.log | tail -1
else
print_warning "No relay.log found"
fi
# Test current configuration values
RELAY_INFO=$(curl -s -H "Accept: application/nostr+json" "$HTTP_URL/")
MIN_POW_DIFF=$(echo "$RELAY_INFO" | jq -r '.limitation.min_pow_difficulty')
print_info "Current min_pow_difficulty from NIP-11: $MIN_POW_DIFF"
# For now, just verify the configuration is readable
if [ "$MIN_POW_DIFF" != "null" ] && [ "$MIN_POW_DIFF" != "missing" ]; then
print_success "✓ PoW configuration is accessible via NIP-11"
record_test_result "PoW Configuration Via Environment Variables" "PASS" "min_pow_difficulty=$MIN_POW_DIFF"
return 0
else
print_error "✗ PoW configuration not accessible"
record_test_result "PoW Configuration Via Environment Variables" "FAIL" "Cannot read min_pow_difficulty"
return 1
fi
}
# Test NIP-13 reference event validation
test_nip13_reference_event() {
print_test_header "NIP-13 Reference Event Validation"
# This is the official NIP-13 reference event
NIP13_REF_EVENT='{"id":"000006d8c378af1779d2feebc7603a125d99eca0ccf1085959b307f64e5dd358","pubkey":"a48380f4cfcc1ad5378294fcac36439770f9c878dd880ffa94bb74ea54a6f243","created_at":1651794653,"kind":1,"tags":[["nonce","776797","20"]],"content":"It'\''s just me mining my own business","sig":"284622fc0a3f4f1303455d5175f7ba962a3300d136085b9566801bc2e0699de0c7e31e44c81fb40ad9049173742e904713c3594a1da0fc5d2382a25c11aba977"}'
print_info "Testing NIP-13 reference event from specification:"
echo "$NIP13_REF_EVENT" | jq '.'
echo ""
# Send reference event to relay via WebSocket using websocat
print_info "Sending NIP-13 reference event to relay..."
# Create EVENT message in Nostr format
REF_EVENT_MESSAGE="[\"EVENT\",$NIP13_REF_EVENT]"
# Send to relay and capture response
if command -v websocat &> /dev/null; then
RESPONSE=$(echo "$REF_EVENT_MESSAGE" | timeout 10s websocat "$RELAY_URL" 2>&1 || echo "Connection failed")
print_info "Relay response: $RESPONSE"
if [[ "$RESPONSE" == *"Connection failed"* ]] || [[ -z "$RESPONSE" ]]; then
print_error "✗ Failed to connect to relay or no response"
record_test_result "NIP-13 Reference Event Validation" "FAIL" "Connection failed or timeout"
return 1
elif [[ "$RESPONSE" == *"true"* ]]; then
print_success "✓ NIP-13 reference event accepted"
record_test_result "NIP-13 Reference Event Validation" "PASS" "Reference event accepted"
return 0
else
print_error "✗ NIP-13 reference event rejected"
record_test_result "NIP-13 Reference Event Validation" "FAIL" "Reference event rejected: $RESPONSE"
return 1
fi
else
print_error "websocat not found - required for testing"
record_test_result "NIP-13 Reference Event Validation" "SKIP" "websocat not available"
return 0
fi
}
# Print test summary
print_test_summary() {
echo ""
echo -e "${BOLD}=== TEST SUMMARY ===${RESET}"
echo "Total tests run: $TEST_COUNT"
echo -e "${GREEN}Passed: $PASSED_COUNT${RESET}"
echo -e "${RED}Failed: $FAILED_COUNT${RESET}"
if [ $FAILED_COUNT -gt 0 ]; then
echo ""
echo -e "${RED}${BOLD}Failed tests:${RESET}"
for result in "${TEST_RESULTS[@]}"; do
IFS='|' read -r name status details <<< "$result"
if [ "$status" = "FAIL" ]; then
echo -e " ${RED}$name${RESET}"
if [ -n "$details" ]; then
echo " $details"
fi
fi
done
fi
echo ""
if [ $FAILED_COUNT -eq 0 ]; then
echo -e "${GREEN}${BOLD}🎉 ALL TESTS PASSED!${RESET}"
echo -e "${GREEN}✅ NIP-13 PoW validation is working correctly in the relay${RESET}"
return 0
else
echo -e "${RED}${BOLD}❌ SOME TESTS FAILED${RESET}"
echo "Please review the output above and check relay logs for more details."
return 1
fi
}
# Main test execution
main() {
echo -e "${BOLD}=== NIP-13 Proof of Work Relay Test Suite ===${RESET}"
echo "Testing NIP-13 PoW validation in the C Nostr Relay"
echo "Relay URL: $RELAY_URL"
echo ""
# Check prerequisites
if ! command -v curl &> /dev/null; then
print_error "curl is required but not installed"
exit 1
fi
if ! command -v jq &> /dev/null; then
print_error "jq is required but not installed"
exit 1
fi
if ! command -v websocat &> /dev/null; then
print_warning "websocat not found - WebSocket tests will be skipped"
fi
# Run tests
check_relay_running
test_nip11_pow_support
test_event_without_pow
test_event_with_pow
test_pow_configuration
test_nip13_reference_event
# Print summary
print_test_summary
exit $?
}
# Run main function
main "$@"

539
tests/40_nip_test.sh Executable file
View File

@@ -0,0 +1,539 @@
#!/bin/bash
# NIP-40 Expiration Timestamp Test Suite for C Nostr Relay
# Tests expiration timestamp handling in the relay's event processing pipeline
set -e # Exit on error
# Color constants
RED='\033[31m'
GREEN='\033[32m'
YELLOW='\033[33m'
BLUE='\033[34m'
BOLD='\033[1m'
RESET='\033[0m'
# Test configuration
RELAY_URL="ws://127.0.0.1:8888"
HTTP_URL="http://127.0.0.1:8888"
TEST_COUNT=0
PASSED_COUNT=0
FAILED_COUNT=0
# Test results tracking
declare -a TEST_RESULTS=()
print_info() {
echo -e "${BLUE}[INFO]${RESET} $1"
}
print_success() {
echo -e "${GREEN}${BOLD}[SUCCESS]${RESET} $1"
}
print_warning() {
echo -e "${YELLOW}[WARNING]${RESET} $1"
}
print_error() {
echo -e "${RED}${BOLD}[ERROR]${RESET} $1"
}
print_test_header() {
TEST_COUNT=$((TEST_COUNT + 1))
echo ""
echo -e "${BOLD}=== TEST $TEST_COUNT: $1 ===${RESET}"
}
record_test_result() {
local test_name="$1"
local result="$2"
local details="$3"
TEST_RESULTS+=("$test_name|$result|$details")
if [ "$result" = "PASS" ]; then
PASSED_COUNT=$((PASSED_COUNT + 1))
print_success "PASS: $test_name"
else
FAILED_COUNT=$((FAILED_COUNT + 1))
print_error "FAIL: $test_name"
if [ -n "$details" ]; then
echo " Details: $details"
fi
fi
}
# Check if relay is running
check_relay_running() {
print_info "Checking if relay is running..."
if ! curl -s -H "Accept: application/nostr+json" "$HTTP_URL/" >/dev/null 2>&1; then
print_error "Relay is not running or not accessible at $HTTP_URL"
print_info "Please start the relay with: ./make_and_restart_relay.sh"
exit 1
fi
print_success "Relay is running and accessible"
}
# Test NIP-11 relay information includes NIP-40
test_nip11_expiration_support() {
print_test_header "NIP-11 Expiration Support Advertisement"
print_info "Fetching relay information..."
RELAY_INFO=$(curl -s -H "Accept: application/nostr+json" "$HTTP_URL/")
echo "Relay Info Response:"
echo "$RELAY_INFO" | jq '.'
echo ""
# Check if NIP-40 is in supported_nips
if echo "$RELAY_INFO" | jq -e '.supported_nips | index(40)' >/dev/null 2>&1; then
print_success "✓ NIP-40 found in supported_nips array"
NIP40_SUPPORTED=true
else
print_error "✗ NIP-40 not found in supported_nips array"
NIP40_SUPPORTED=false
fi
if [ "$NIP40_SUPPORTED" = true ]; then
record_test_result "NIP-11 Expiration Support Advertisement" "PASS" "NIP-40 advertised in relay info"
return 0
else
record_test_result "NIP-11 Expiration Support Advertisement" "FAIL" "NIP-40 not advertised"
return 1
fi
}
# Helper function to create event with expiration tag
create_event_with_expiration() {
local content="$1"
local expiration_timestamp="$2"
local private_key="91ba716fa9e7ea2fcbad360cf4f8e0d312f73984da63d90f524ad61a6a1e7dbe"
if ! command -v nak &> /dev/null; then
echo ""
return 1
fi
# Create event with expiration tag
nak event --sec "$private_key" -c "$content" -t "expiration=$expiration_timestamp" --ts $(date +%s)
}
# Helper function to send event and check response
send_event_and_check() {
local event_json="$1"
local expected_result="$2" # "accept" or "reject"
local description="$3"
if [ -z "$event_json" ]; then
return 1
fi
# Create EVENT message
local event_message="[\"EVENT\",$event_json]"
# Send to relay
if command -v websocat &> /dev/null; then
local response=$(echo "$event_message" | timeout 5s websocat "$RELAY_URL" 2>&1 || echo "Connection failed")
print_info "Relay response: $response"
if [[ "$response" == *"Connection failed"* ]]; then
print_error "✗ Failed to connect to relay"
return 1
elif [[ "$expected_result" == "accept" && "$response" == *"true"* ]]; then
print_success "$description accepted as expected"
return 0
elif [[ "$expected_result" == "reject" && "$response" == *"false"* ]]; then
print_success "$description rejected as expected"
return 0
elif [[ "$expected_result" == "accept" && "$response" == *"false"* ]]; then
print_error "$description unexpectedly rejected: $response"
return 1
elif [[ "$expected_result" == "reject" && "$response" == *"true"* ]]; then
print_error "$description unexpectedly accepted: $response"
return 1
else
print_warning "? Unclear response for $description: $response"
return 1
fi
else
print_error "websocat not found - required for testing"
return 1
fi
}
# Test event without expiration tag
test_event_without_expiration() {
print_test_header "Event Submission Without Expiration Tag"
if ! command -v nak &> /dev/null; then
print_warning "nak command not found - skipping expiration tests"
record_test_result "Event Submission Without Expiration Tag" "SKIP" "nak not available"
return 0
fi
print_info "Creating event without expiration tag..."
local private_key="91ba716fa9e7ea2fcbad360cf4f8e0d312f73984da63d90f524ad61a6a1e7dbe"
local event_json=$(nak event --sec "$private_key" -c "Test event without expiration" --ts $(date +%s))
print_info "Generated event:"
echo "$event_json" | jq '.'
echo ""
if send_event_and_check "$event_json" "accept" "Event without expiration tag"; then
record_test_result "Event Submission Without Expiration Tag" "PASS" "Non-expiring event accepted"
return 0
else
record_test_result "Event Submission Without Expiration Tag" "FAIL" "Non-expiring event handling failed"
return 1
fi
}
# Test event with future expiration (should be accepted)
test_event_with_future_expiration() {
print_test_header "Event Submission With Future Expiration"
if ! command -v nak &> /dev/null; then
record_test_result "Event Submission With Future Expiration" "SKIP" "nak not available"
return 0
fi
print_info "Creating event with future expiration (1 hour from now)..."
local future_timestamp=$(($(date +%s) + 3600)) # 1 hour from now
local event_json=$(create_event_with_expiration "Test event expiring in 1 hour" "$future_timestamp")
if [ -z "$event_json" ]; then
record_test_result "Event Submission With Future Expiration" "FAIL" "Failed to create event"
return 1
fi
print_info "Generated event (expires at $future_timestamp):"
echo "$event_json" | jq '.'
echo ""
if send_event_and_check "$event_json" "accept" "Event with future expiration"; then
record_test_result "Event Submission With Future Expiration" "PASS" "Future-expiring event accepted"
return 0
else
record_test_result "Event Submission With Future Expiration" "FAIL" "Future-expiring event rejected"
return 1
fi
}
# Test event with past expiration (should be rejected in strict mode)
test_event_with_past_expiration() {
print_test_header "Event Submission With Past Expiration"
if ! command -v nak &> /dev/null; then
record_test_result "Event Submission With Past Expiration" "SKIP" "nak not available"
return 0
fi
print_info "Creating event with past expiration (1 hour ago)..."
local past_timestamp=$(($(date +%s) - 3600)) # 1 hour ago
local event_json=$(create_event_with_expiration "Test event expired 1 hour ago" "$past_timestamp")
if [ -z "$event_json" ]; then
record_test_result "Event Submission With Past Expiration" "FAIL" "Failed to create event"
return 1
fi
print_info "Generated event (expired at $past_timestamp):"
echo "$event_json" | jq '.'
echo ""
# In strict mode (default), this should be rejected
if send_event_and_check "$event_json" "reject" "Event with past expiration"; then
record_test_result "Event Submission With Past Expiration" "PASS" "Expired event correctly rejected in strict mode"
return 0
else
record_test_result "Event Submission With Past Expiration" "FAIL" "Expired event handling failed"
return 1
fi
}
# Test event with expiration within grace period
test_event_within_grace_period() {
print_test_header "Event Submission Within Grace Period"
if ! command -v nak &> /dev/null; then
record_test_result "Event Submission Within Grace Period" "SKIP" "nak not available"
return 0
fi
print_info "Creating event with expiration within grace period (2 minutes ago, grace period is 5 minutes)..."
local grace_timestamp=$(($(date +%s) - 120)) # 2 minutes ago (within 5 minute grace period)
local event_json=$(create_event_with_expiration "Test event within grace period" "$grace_timestamp")
if [ -z "$event_json" ]; then
record_test_result "Event Submission Within Grace Period" "FAIL" "Failed to create event"
return 1
fi
print_info "Generated event (expired at $grace_timestamp, within grace period):"
echo "$event_json" | jq '.'
echo ""
# Should be accepted due to grace period
if send_event_and_check "$event_json" "accept" "Event within grace period"; then
record_test_result "Event Submission Within Grace Period" "PASS" "Event within grace period accepted"
return 0
else
record_test_result "Event Submission Within Grace Period" "FAIL" "Grace period handling failed"
return 1
fi
}
# Test event filtering in subscriptions
test_expiration_filtering_in_subscriptions() {
print_test_header "Expiration Filtering in Subscriptions"
if ! command -v nak &> /dev/null || ! command -v websocat &> /dev/null; then
record_test_result "Expiration Filtering in Subscriptions" "SKIP" "Required tools not available"
return 0
fi
print_info "Setting up test events for subscription filtering..."
# First, create a few events with different expiration times
local private_key="91ba716fa9e7ea2fcbad360cf4f8e0d312f73984da63d90f524ad61a6a1e7dbe"
# Event 1: No expiration (should be returned)
local event1=$(nak event --sec "$private_key" -c "Event without expiration for filtering test" --ts $(date +%s))
# Event 2: Future expiration (should be returned)
local future_timestamp=$(($(date +%s) + 1800)) # 30 minutes from now
local event2=$(create_event_with_expiration "Event with future expiration for filtering test" "$future_timestamp")
# Event 3: Past expiration (should NOT be returned if filtering is enabled)
local past_timestamp=$(($(date +%s) - 3600)) # 1 hour ago
local event3=$(create_event_with_expiration "Event with past expiration for filtering test" "$past_timestamp")
print_info "Publishing test events..."
# Note: We expect event3 to be rejected on submission in strict mode,
# so we'll create it with a slightly more recent expiration that might get through
local recent_past=$(($(date +%s) - 600)) # 10 minutes ago (outside grace period)
local event3_recent=$(create_event_with_expiration "Recently expired event for filtering test" "$recent_past")
# Try to submit all events (some may be rejected)
echo "[\"EVENT\",$event1]" | timeout 3s websocat "$RELAY_URL" >/dev/null 2>&1 || true
echo "[\"EVENT\",$event2]" | timeout 3s websocat "$RELAY_URL" >/dev/null 2>&1 || true
echo "[\"EVENT\",$event3_recent]" | timeout 3s websocat "$RELAY_URL" >/dev/null 2>&1 || true
sleep 2 # Let events settle
print_info "Testing subscription filtering..."
# Create subscription for recent events
local req_message='["REQ","filter_test",{"kinds":[1],"limit":10}]'
local response=$(echo -e "$req_message\n[\"CLOSE\",\"filter_test\"]" | timeout 5s websocat "$RELAY_URL" 2>/dev/null || echo "")
print_info "Subscription response:"
echo "$response"
echo ""
# Count events that contain our test content
local no_exp_count=0
local future_exp_count=0
local past_exp_count=0
if echo "$response" | grep -q "Event without expiration for filtering test"; then
no_exp_count=1
print_success "✓ Event without expiration found in subscription results"
fi
if echo "$response" | grep -q "Event with future expiration for filtering test"; then
future_exp_count=1
print_success "✓ Event with future expiration found in subscription results"
fi
if echo "$response" | grep -q "Recently expired event for filtering test"; then
past_exp_count=1
print_warning "✗ Recently expired event found in subscription results (should be filtered)"
else
print_success "✓ Recently expired event properly filtered from subscription results"
fi
# Evaluate results
local expected_events=$((no_exp_count + future_exp_count))
if [ $expected_events -ge 1 ] && [ $past_exp_count -eq 0 ]; then
record_test_result "Expiration Filtering in Subscriptions" "PASS" "Expired events properly filtered from subscriptions"
return 0
else
record_test_result "Expiration Filtering in Subscriptions" "FAIL" "Expiration filtering not working properly in subscriptions"
return 1
fi
}
# Test malformed expiration tags
test_malformed_expiration_tags() {
print_test_header "Handling of Malformed Expiration Tags"
if ! command -v nak &> /dev/null; then
record_test_result "Handling of Malformed Expiration Tags" "SKIP" "nak not available"
return 0
fi
print_info "Testing events with malformed expiration tags..."
local private_key="91ba716fa9e7ea2fcbad360cf4f8e0d312f73984da63d90f524ad61a6a1e7dbe"
# Test 1: Non-numeric expiration value
local event1=$(nak event --sec "$private_key" -c "Event with non-numeric expiration" -t "expiration=not_a_number" --ts $(date +%s))
# Test 2: Empty expiration value
local event2=$(nak event --sec "$private_key" -c "Event with empty expiration" -t "expiration=" --ts $(date +%s))
print_info "Testing non-numeric expiration value..."
if send_event_and_check "$event1" "accept" "Event with non-numeric expiration (should be treated as no expiration)"; then
print_success "✓ Non-numeric expiration handled gracefully"
malformed_test1=true
else
malformed_test1=false
fi
print_info "Testing empty expiration value..."
if send_event_and_check "$event2" "accept" "Event with empty expiration (should be treated as no expiration)"; then
print_success "✓ Empty expiration handled gracefully"
malformed_test2=true
else
malformed_test2=false
fi
if [ "$malformed_test1" = true ] && [ "$malformed_test2" = true ]; then
record_test_result "Handling of Malformed Expiration Tags" "PASS" "Malformed expiration tags handled gracefully"
return 0
else
record_test_result "Handling of Malformed Expiration Tags" "FAIL" "Malformed expiration tag handling failed"
return 1
fi
}
# Test configuration via environment variables
test_expiration_configuration() {
print_test_header "Expiration Configuration Via Environment Variables"
print_info "Testing expiration configuration from relay logs..."
if [ -f "relay.log" ]; then
print_info "Current configuration from logs:"
grep "Expiration Configuration:" relay.log | tail -1 || print_warning "No expiration configuration found in logs"
else
print_warning "No relay.log found"
fi
# The relay should be running with default configuration
print_info "Default configuration should be:"
print_info " enabled=true"
print_info " strict_mode=true (rejects expired events on submission)"
print_info " filter_responses=true (filters expired events from responses)"
print_info " grace_period=300 seconds (5 minutes)"
# Test current behavior matches expected default configuration
print_info "Configuration test based on observed behavior:"
# Check if NIP-40 is advertised (indicates enabled=true)
if curl -s -H "Accept: application/nostr+json" "$HTTP_URL/" | jq -e '.supported_nips | index(40)' >/dev/null 2>&1; then
print_success "✓ NIP-40 support advertised (enabled=true)"
config_test=true
else
print_error "✗ NIP-40 not advertised (may be disabled)"
config_test=false
fi
if [ "$config_test" = true ]; then
record_test_result "Expiration Configuration Via Environment Variables" "PASS" "Expiration configuration is accessible and working"
return 0
else
record_test_result "Expiration Configuration Via Environment Variables" "FAIL" "Expiration configuration issues detected"
return 1
fi
}
# Print test summary
print_test_summary() {
echo ""
echo -e "${BOLD}=== TEST SUMMARY ===${RESET}"
echo "Total tests run: $TEST_COUNT"
echo -e "${GREEN}Passed: $PASSED_COUNT${RESET}"
echo -e "${RED}Failed: $FAILED_COUNT${RESET}"
if [ $FAILED_COUNT -gt 0 ]; then
echo ""
echo -e "${RED}${BOLD}Failed tests:${RESET}"
for result in "${TEST_RESULTS[@]}"; do
IFS='|' read -r name status details <<< "$result"
if [ "$status" = "FAIL" ]; then
echo -e " ${RED}$name${RESET}"
if [ -n "$details" ]; then
echo " $details"
fi
fi
done
fi
echo ""
if [ $FAILED_COUNT -eq 0 ]; then
echo -e "${GREEN}${BOLD}🎉 ALL TESTS PASSED!${RESET}"
echo -e "${GREEN}✅ NIP-40 Expiration Timestamp support is working correctly in the relay${RESET}"
return 0
else
echo -e "${RED}${BOLD}❌ SOME TESTS FAILED${RESET}"
echo "Please review the output above and check relay logs for more details."
return 1
fi
}
# Main test execution
main() {
echo -e "${BOLD}=== NIP-40 Expiration Timestamp Relay Test Suite ===${RESET}"
echo "Testing NIP-40 Expiration Timestamp support in the C Nostr Relay"
echo "Relay URL: $RELAY_URL"
echo ""
# Check prerequisites
if ! command -v curl &> /dev/null; then
print_error "curl is required but not installed"
exit 1
fi
if ! command -v jq &> /dev/null; then
print_error "jq is required but not installed"
exit 1
fi
if ! command -v websocat &> /dev/null; then
print_warning "websocat not found - WebSocket tests will be skipped"
fi
if ! command -v nak &> /dev/null; then
print_warning "nak not found - Event generation tests will be skipped"
print_info "Install with: go install github.com/fiatjaf/nak@latest"
fi
# Run tests
check_relay_running
test_nip11_expiration_support
test_event_without_expiration
test_event_with_future_expiration
test_event_with_past_expiration
test_event_within_grace_period
test_expiration_filtering_in_subscriptions
test_malformed_expiration_tags
test_expiration_configuration
# Print summary
print_test_summary
exit $?
}
# Run main function
main "$@"