diff --git a/README.md b/README.md index e3d1ded..9f8d6da 100644 --- a/README.md +++ b/README.md @@ -14,76 +14,9 @@ Do NOT modify the formatting, add emojis, or change the text. Keep the simple fo - [x] NIP-13: Proof of Work - [x] NIP-15: End of Stored Events Notice - [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 - -## NIP-13: Proof of Work Configuration - -The relay supports NIP-13 Proof of Work validation with configurable settings. PoW validation helps prevent spam and ensures computational commitment from event publishers. - -### Environment Variables - -Configure PoW validation using these environment variables: - -- `RELAY_POW_ENABLED` - Enable/disable PoW validation (default: `1`) - - `1`, `true`, or `yes` to enable - - `0`, `false`, or `no` to disable - -- `RELAY_MIN_POW_DIFFICULTY` - Minimum required difficulty (default: `0`) - - Range: `0-64` (reasonable bounds) - - `0` = no minimum requirement (events without PoW are accepted) - - Higher values require more computational work - -- `RELAY_POW_MODE` - Validation mode (default: `basic`) - - `basic` - Basic PoW validation - - `full` - Full validation with nonce tag requirements - - `strict` - Strict anti-spam mode with committed target validation - - `disabled` - Disable PoW validation entirely - -### Examples - -```bash -# Basic setup - accept events with or without PoW -export RELAY_POW_ENABLED=1 -export RELAY_MIN_POW_DIFFICULTY=0 -export RELAY_POW_MODE=basic - -# Anti-spam setup - require minimum difficulty 16 -export RELAY_POW_ENABLED=1 -export RELAY_MIN_POW_DIFFICULTY=16 -export RELAY_POW_MODE=strict - -# Disable PoW validation completely -export RELAY_POW_ENABLED=0 -``` - -### Behavior - -- **min_difficulty=0**: Events without PoW are accepted; events with PoW are validated -- **min_difficulty>0**: All events must have valid PoW meeting minimum difficulty -- **strict mode**: Additional validation prevents difficulty commitment gaming -- **NIP-11 integration**: PoW configuration is advertised via relay information document - -### Testing - -Run the comprehensive PoW test suite: - -```bash -./tests/13_nip_test.sh -``` - -The test suite validates: -- NIP-11 PoW support advertisement -- Event acceptance without PoW (when min_difficulty=0) -- Event validation with valid PoW -- Configuration via environment variables -- NIP-13 reference event validation - diff --git a/admin_spec.md b/admin_spec.md new file mode 100644 index 0000000..b774c0c --- /dev/null +++ b/admin_spec.md @@ -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 \ + --server-key \ + --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 +``` + +**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. diff --git a/c-relay-x86_64 b/c-relay-x86_64 deleted file mode 100755 index 2b58d2c..0000000 Binary files a/c-relay-x86_64 and /dev/null differ diff --git a/db/c_nostr_relay.db b/db/c_nostr_relay.db index cb5cdb2..a79b8e8 100644 Binary files a/db/c_nostr_relay.db and b/db/c_nostr_relay.db differ diff --git a/db/c_nostr_relay.db-shm b/db/c_nostr_relay.db-shm index c7a42c3..0b1a672 100644 Binary files a/db/c_nostr_relay.db-shm and b/db/c_nostr_relay.db-shm differ diff --git a/db/c_nostr_relay.db-wal b/db/c_nostr_relay.db-wal index b10bb0a..02a34c8 100644 Binary files a/db/c_nostr_relay.db-wal and b/db/c_nostr_relay.db-wal differ diff --git a/relay.log b/relay.log index a9003d0..6168b99 100644 --- a/relay.log +++ b/relay.log @@ -3,6 +3,8 @@ [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 @@ -21,37 +23,14 @@ [INFO] WebSocket connection established [INFO] Received WebSocket message [INFO] Handling EVENT message with full NIP-01 validation -[INFO] PoW validated: difficulty=10, target=8, nonce=1839 [SUCCESS] Event stored in database [SUCCESS] Event validated and stored successfully [INFO] WebSocket connection closed -[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] 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] Received WebSocket message -[INFO] WebSocket connection closed [INFO] WebSocket connection established [INFO] Received WebSocket message [INFO] Handling EVENT message with full NIP-01 validation -[INFO] PoW validated: difficulty=21, target=20, nonce=776797 -[SUCCESS] Event stored in database -[SUCCESS] Event validated and stored successfully +[WARNING] Event rejected: expired timestamp [INFO] WebSocket connection closed -[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 @@ -61,37 +40,9 @@ [INFO] WebSocket connection established [INFO] Received WebSocket message [INFO] Handling EVENT message with full NIP-01 validation -[INFO] PoW validated: difficulty=8, target=8, nonce=385 [SUCCESS] Event stored in database [SUCCESS] Event validated and stored successfully [INFO] WebSocket connection closed -[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] 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] Received WebSocket message -[INFO] WebSocket connection closed -[INFO] WebSocket connection established -[INFO] Received WebSocket message -[INFO] Handling EVENT message with full NIP-01 validation -[INFO] PoW validated: difficulty=21, target=20, nonce=776797 -[WARNING] Event already exists in database -[SUCCESS] Event validated and stored successfully -[INFO] WebSocket connection closed -[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 @@ -101,17 +52,32 @@ [INFO] WebSocket connection established [INFO] Received WebSocket message [INFO] Handling EVENT message with full NIP-01 validation -[INFO] PoW validated: difficulty=8, target=8, nonce=1669 +[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 '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 'filter_test' (total: 0) +[INFO] Closed subscription: filter_test +[INFO] WebSocket connection closed +[WARNING] Subscription '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 EVENT message with full NIP-01 validation [SUCCESS] Event stored in database [SUCCESS] Event validated and stored successfully [INFO] WebSocket connection closed [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 -[INFO] PoW validated: difficulty=21, target=20, nonce=776797 -[WARNING] Event already exists in database -[SUCCESS] Event validated and stored successfully -[INFO] WebSocket connection closed diff --git a/relay.pid b/relay.pid index 34db337..62dfa5e 100644 --- a/relay.pid +++ b/relay.pid @@ -1 +1 @@ -736301 +743964 diff --git a/src/main.c b/src/main.c index 1d3b7f7..758215a 100644 --- a/src/main.c +++ b/src/main.c @@ -97,6 +97,24 @@ static struct pow_config g_pow_config = { .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 +}; + ///////////////////////////////////////////////////////////////////////////////////////// ///////////////////////////////////////////////////////////////////////////////////////// @@ -217,6 +235,12 @@ int handle_nip11_http_request(struct lws* wsi, const char* accept_header); 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); + ///////////////////////////////////////////////////////////////////////////////////////// ///////////////////////////////////////////////////////////////////////////////////////// @@ -651,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); @@ -1268,6 +1305,7 @@ void init_relay_info() { 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 @@ -1757,6 +1795,145 @@ int validate_event_pow(cJSON* event, char* error_message, size_t error_size) { 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 @@ -2165,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)) { @@ -2293,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")); @@ -2388,14 +2578,20 @@ int handle_event_message(cJSON* event, char* error_message, size_t error_size) { return pow_result; // PoW validation failed, error message already set } - // Step 4: Complete event validation (combines structure + signature + additional checks) + // 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 5: 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"); @@ -2435,7 +2631,7 @@ int handle_event_message(cJSON* event, char* error_message, size_t error_size) { } } - // Step 6: Store event in database + // Step 7: Store event in database if (store_event(event) == 0) { error_message[0] = '\0'; // Success - empty error message log_success("Event validated and stored successfully"); @@ -2805,6 +3001,9 @@ int main(int argc, char* argv[]) { // 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 diff --git a/tests/40_nip_test.sh b/tests/40_nip_test.sh new file mode 100755 index 0000000..464bf4f --- /dev/null +++ b/tests/40_nip_test.sh @@ -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 "$@" \ No newline at end of file