v2.1.18 - Fixed caching service integration: aligned caching_status response schema, fixed launcher to use -c config_path instead of --pg-conn, added caching service config fields to UI, replaced misleading not-implemented placeholder with accurate auth-failure messaging

This commit is contained in:
Laan Tungir
2026-07-25 11:19:37 -04:00
parent 179b160cf3
commit 3035e71ca5
9 changed files with 266 additions and 56 deletions
+16 -1
View File
@@ -686,6 +686,21 @@ WEB OF TRUST
<input type="number" id="caching-inbox-idle-poll" placeholder="5000">
</div>
<div class="form-group">
<label for="caching-service-binary-path">Caching Service Binary Path:</label>
<input type="text" id="caching-service-binary-path" placeholder="/absolute/path/to/caching_relay" value="/home/user/lt/caching_relay/caching_relay">
</div>
<div class="form-group">
<label for="caching-service-config-path">Caching Service Config Path:</label>
<input type="text" id="caching-service-config-path" placeholder="/absolute/path/to/caching_relay_config.jsonc" value="/home/user/lt/caching_relay/caching_relay_config.jsonc">
</div>
<div class="form-group">
<label for="caching-service-pg-conn">Caching Service PG Connection (future):</label>
<input type="text" id="caching-service-pg-conn" placeholder="host=localhost port=5432 dbname=crelay user=crelay password=crelay" value="host=localhost port=5432 dbname=crelay user=crelay password=crelay">
</div>
<div class="inline-buttons">
<button type="button" id="caching-apply-btn">APPLY CONFIGURATION</button>
<button type="button" id="caching-reset-progress-btn">RESET BACKFILL PROGRESS</button>
@@ -695,7 +710,7 @@ WEB OF TRUST
<div class="input-group">
<h3>Caching Service Control</h3>
<p>Start or stop the external caching service process. Requires <code>caching_service_binary_path</code> and <code>caching_service_pg_conn</code> to be set in Configuration.</p>
<p>Start or stop the external caching service process. Set <code>caching_service_binary_path</code> and <code>caching_service_pg_conn</code> above and click APPLY CONFIGURATION before starting.</p>
<div class="inline-buttons">
<button type="button" id="caching-start-service-btn">START CACHING SERVICE</button>
<button type="button" id="caching-stop-service-btn">STOP CACHING SERVICE</button>
+65 -18
View File
@@ -6839,7 +6839,10 @@ const CACHING_CONFIG_FIELDS = [
{ key: 'caching_backfill_tick_interval_ms',field: 'caching-backfill-tick-interval', type: 'integer' },
{ key: 'caching_inbox_batch_size', field: 'caching-inbox-batch-size', type: 'integer' },
{ key: 'caching_inbox_active_poll_ms', field: 'caching-inbox-active-poll', type: 'integer' },
{ key: 'caching_inbox_idle_poll_ms', field: 'caching-inbox-idle-poll', type: 'integer' }
{ key: 'caching_inbox_idle_poll_ms', field: 'caching-inbox-idle-poll', type: 'integer' },
{ key: 'caching_service_binary_path', field: 'caching-service-binary-path', type: 'string' },
{ key: 'caching_service_config_path', field: 'caching-service-config-path', type: 'string' },
{ key: 'caching_service_pg_conn', field: 'caching-service-pg-conn', type: 'string' }
];
// Helper: read a config value from currentConfig (which stores values in tags as [key, value])
@@ -6882,24 +6885,39 @@ async function fetchCachingStatus() {
});
// Attempt to fetch service/inbox status via the caching_status system command.
// If the backend has not implemented it yet, show a graceful "unavailable" message.
// Show a neutral "loading" placeholder; handleCachingStatusResponse() will
// overwrite it when the response arrives. If the command fails (e.g. admin
// auth rejection), the catch block surfaces the real error.
const serviceStatusEl = document.getElementById('caching-service-status');
const inboxStatusEl = document.getElementById('caching-inbox-status');
if (serviceStatusEl) {
serviceStatusEl.innerHTML = '<p>Service status unavailable (caching_status command not implemented on relay).</p>';
serviceStatusEl.innerHTML = '<p>Loading caching service status...</p>';
}
if (inboxStatusEl) {
inboxStatusEl.innerHTML = '<p>Inbox status unavailable (caching_status command not implemented on relay).</p>';
inboxStatusEl.innerHTML = '<p>Loading relay inbox status...</p>';
}
// Best-effort: send caching_status system command. Response handling is added in
// handleSystemCommandResponse() below; if unimplemented, the status blocks remain
// at the "unavailable" message set above.
// Best-effort: send caching_status system command. Response handling is in
// handleSystemCommandResponse() -> handleCachingStatusResponse() below.
try {
await sendAdminCommand(['system_command', 'caching_status']);
} catch (e) {
console.log('caching_status command not available: ' + e.message);
console.log('caching_status command failed: ' + e.message);
const errMsg = escapeHtml(e.message || 'Unknown error');
// Detect admin authorization failures and surface a clear message,
// since the relay rejects the kind 23456 event before the command
// handler ever runs.
const isAuthError = /unauthorized admin event attempt|invalid admin pubkey|not admin/i.test(e.message || '');
const authNote = isAuthError
? ' <em>(Your browser pubkey is not registered as an admin on this relay. Load the admin private key in your Nostr extension.)</em>'
: '';
if (serviceStatusEl) {
serviceStatusEl.innerHTML = `<p style="color:red">Failed to load service status: ${errMsg}${authNote}</p>`;
}
if (inboxStatusEl) {
inboxStatusEl.innerHTML = `<p style="color:red">Failed to load inbox status: ${errMsg}${authNote}</p>`;
}
}
} catch (error) {
@@ -7041,13 +7059,22 @@ function handleCachingStatusResponse(responseData) {
const data = responseData.data || responseData;
// Service status block
// Helper: pick the first defined value from a list of candidate keys/paths.
function pick(...vals) {
for (const v of vals) {
if (v !== undefined && v !== null) return v;
}
return null;
}
// Service status block (external caching service process)
if (serviceStatusEl) {
const service = data.service || data.caching_service || {};
const enabled = service.enabled !== undefined ? service.enabled : (data.enabled !== undefined ? data.enabled : null);
const running = service.running !== undefined ? service.running : (data.running !== undefined ? data.running : null);
const connectedRelays = service.connected_relays !== undefined ? service.connected_relays : (data.connected_relays !== undefined ? data.connected_relays : null);
const eventsCached = service.events_cached !== undefined ? service.events_cached : (data.events_cached !== undefined ? data.events_cached : null);
// Backward-compat: fall back to top-level flat fields if nested object absent.
const enabled = pick(service.enabled, data.caching_enabled, data.enabled);
const running = pick(service.running, data.running);
const connectedRelays = pick(service.connected_relays, data.connected_relays);
const eventsCached = pick(service.events_cached, data.events_cached);
let html = '<ul style="list-style:none;padding:0;margin:0;">';
if (enabled !== null) html += `<li><strong>Enabled:</strong> ${escapeHtml(String(enabled))}</li>`;
@@ -7058,19 +7085,39 @@ function handleCachingStatusResponse(responseData) {
serviceStatusEl.innerHTML = html;
}
// Inbox status block
// Inbox status block (relay-owned inbox poller)
if (inboxStatusEl) {
const inbox = data.inbox || data.caching_inbox || {};
const inboxEnabled = inbox.enabled !== undefined ? inbox.enabled : null;
const inboxRunning = inbox.running !== undefined ? inbox.running : null;
const queueDepth = inbox.queue_depth !== undefined ? inbox.queue_depth : null;
const lastPoll = inbox.last_poll !== undefined ? inbox.last_poll : null;
// Backward-compat: derive from flat top-level inbox_* fields if nested object absent.
const inboxEnabled = pick(inbox.enabled, data.caching_inbox_enabled);
const inboxRunning = pick(inbox.running);
const queueDepth = pick(inbox.queue_depth,
(data.inbox_pending_live !== undefined && data.inbox_pending_backfill !== undefined)
? (Number(data.inbox_pending_live) + Number(data.inbox_pending_backfill))
: undefined);
const lastPoll = pick(inbox.last_poll);
const totalDequeued = pick(inbox.total_dequeued, data.inbox_total_dequeued);
const totalAccepted = pick(inbox.total_accepted, data.inbox_total_accepted);
const totalRejected = pick(inbox.total_rejected, data.inbox_total_rejected);
const totalDuplicates = pick(inbox.total_duplicates, data.inbox_total_duplicates);
const lastBatchSize = pick(inbox.last_batch_size, data.inbox_last_batch_size);
const pendingLive = pick(inbox.pending_live, data.inbox_pending_live);
const pendingBackfill = pick(inbox.pending_backfill, data.inbox_pending_backfill);
const oldestAge = pick(inbox.oldest_age_seconds, data.inbox_oldest_age_seconds);
let html = '<ul style="list-style:none;padding:0;margin:0;">';
if (inboxEnabled !== null) html += `<li><strong>Inbox Enabled:</strong> ${escapeHtml(String(inboxEnabled))}</li>`;
if (inboxRunning !== null) html += `<li><strong>Inbox Running:</strong> ${escapeHtml(String(inboxRunning))}</li>`;
if (queueDepth !== null) html += `<li><strong>Queue Depth:</strong> ${escapeHtml(String(queueDepth))}</li>`;
if (lastPoll !== null) html += `<li><strong>Last Poll:</strong> ${escapeHtml(String(lastPoll))}</li>`;
if (totalDequeued !== null) html += `<li><strong>Total Dequeued:</strong> ${escapeHtml(String(totalDequeued))}</li>`;
if (totalAccepted !== null) html += `<li><strong>Total Accepted:</strong> ${escapeHtml(String(totalAccepted))}</li>`;
if (totalRejected !== null) html += `<li><strong>Total Rejected:</strong> ${escapeHtml(String(totalRejected))}</li>`;
if (totalDuplicates !== null) html += `<li><strong>Total Duplicates:</strong> ${escapeHtml(String(totalDuplicates))}</li>`;
if (lastBatchSize !== null) html += `<li><strong>Last Batch Size:</strong> ${escapeHtml(String(lastBatchSize))}</li>`;
if (pendingLive !== null) html += `<li><strong>Pending (Live):</strong> ${escapeHtml(String(pendingLive))}</li>`;
if (pendingBackfill !== null) html += `<li><strong>Pending (Backfill):</strong> ${escapeHtml(String(pendingBackfill))}</li>`;
if (oldestAge !== null) html += `<li><strong>Oldest Pending Age:</strong> ${escapeHtml(String(oldestAge))}s</li>`;
html += '</ul>';
inboxStatusEl.innerHTML = html;
}
+113
View File
@@ -0,0 +1,113 @@
# Caching Status "Not Implemented" Fix Plan
## Root Cause
The caching page shows "Service status unavailable (caching_status command not
implemented on relay)" — but the command **is** implemented at
[`src/config.c:4128`](../src/config.c:4128). The real problem is **admin
authorization failure**: every admin command from the browser is rejected at
publish time with:
```
Unauthorized admin event attempt: invalid admin pubkey
```
This rejection happens at [`src/main.c:2249`](../src/main.c:2249) because the
browser's pubkey is not in the relay's `admin_pubkey` config list. The kind
23456 event never reaches the command handler, so `caching_status` never
executes, and the placeholder text persists.
### Confirmed admin key
- Relay admin pubkey (hex): `6a04ab98d9e4774ad806e302dddeb63bea16b5cb5f223ee77478e861bb583eb3`
- Relay admin npub: `npub13lm5wf8dvsdnc2894pkhch9uf8phvw9varrv8zf4sc885hhdmc8q6lx7ks`
- Source: [`.relay.laantungir.net.keys`](../.relay.laantungir.net.keys:1)
The browser extension (nos2x) was using pubkey `8ff74724...`, which is not an
admin on the port 7777 relay.
## Secondary Issue (latent)
Even after auth is fixed, the caching status UI would render **empty blocks**
due to a schema mismatch between backend and frontend:
| Frontend expects ([`api/index.js:7046`](../api/index.js:7046)) | Backend emits ([`src/config.c:4135`](../src/config.c:4135)) |
|---|---|
| `data.service.enabled` | `data.caching_enabled` |
| `data.service.running` | *(not emitted)* |
| `data.inbox.enabled` | `data.caching_inbox_enabled` |
| `data.inbox.queue_depth` | `data.inbox_pending_live` + `data.inbox_pending_backfill` |
| *(not expected)* | `data.inbox_total_dequeued`, `data.inbox_total_accepted`, `data.inbox_total_rejected`, `data.inbox_oldest_age_seconds` |
## Fix Steps
```mermaid
flowchart TD
A[Step 1: Fix admin auth] --> B[Step 2: Align response schema]
B --> C[Step 3: Fix frontend handler]
C --> D[Step 4: Improve error messaging]
D --> E[Step 5: Test end-to-end]
```
### Step 1 — Fix admin authorization (config, user action)
Load the admin private key (corresponding to `6a04ab98...` /
`npub13lm5wf8dvsdnc2894pkhch9uf8phvw9varrv8zf4sc885hhdmc8q6lx7ks`) into the
nos2x browser extension so admin commands authenticate. Use `nak` on the
command line to convert/derive the nsec if needed.
This unblocks **all** admin commands, not just caching.
### Step 2 — Align backend response schema (code, [`src/config.c:4128`](../src/config.c:4128))
Restructure the `data` object in the `caching_status` handler to emit nested
`service` and `inbox` objects matching the frontend handler:
```json
{
"command": "caching_status",
"status": "success",
"data": {
"service": {
"enabled": false,
"running": false,
"connected_relays": 0,
"events_cached": 0
},
"inbox": {
"enabled": false,
"running": true,
"queue_depth": 0,
"last_poll": 0,
"total_dequeued": 0,
"total_accepted": 0,
"total_rejected": 0,
"total_duplicates": 0,
"pending_live": 0,
"pending_backfill": 0,
"oldest_age_seconds": 0
}
}
}
```
Populate `service.running` / `connected_relays` / `events_cached` from the
caching service launcher state if available; otherwise emit zeros/defaults.
### Step 3 — Update frontend handler (code, [`api/index.js:7027`](../api/index.js:7027))
Update `handleCachingStatusResponse()` to render the inbox poller stats the
backend actually produces (dequeued/accepted/rejected/pending/oldest_age), and
map `data.inbox.pending_live + pending_backfill` to queue depth.
### Step 4 — Replace misleading placeholder (code, [`api/index.js:6889`](../api/index.js:6889))
- Change the pre-send placeholder from "caching_status command not implemented"
to a neutral "Loading..." message.
- On auth failure, show the actual error (e.g., "Not authorized: pubkey not
registered as admin") instead of the generic "unavailable" text.
### Step 5 — Test end-to-end on port 7777
Verify the caching page shows real service/inbox status after auth is fixed
and the schema is aligned.
+1 -1
View File
@@ -1 +1 @@
257278
194734
+17 -14
View File
@@ -21,7 +21,7 @@ static pid_t g_caching_child_pid = -1;
// Fork/exec implementation
// ---------------------------------------------------------------------------
static int caching_service_start_fork(const char* binary_path, const char* pg_conn) {
static int caching_service_start_fork(const char* binary_path, const char* config_path) {
if (!binary_path || binary_path[0] == '\0') {
DEBUG_ERROR("caching_service_start: caching_service_binary_path is not set");
return -1;
@@ -49,7 +49,8 @@ static int caching_service_start_fork(const char* binary_path, const char* pg_co
g_caching_child_pid = -1;
}
DEBUG_INFO("caching_service_start: forking '%s' with --pg-conn", binary_path);
DEBUG_INFO("caching_service_start: forking '%s' with config '%s'",
binary_path, config_path ? config_path : "(default)");
pid_t pid = fork();
if (pid < 0) {
@@ -80,18 +81,18 @@ static int caching_service_start_fork(const char* binary_path, const char* pg_co
close(devnull);
}
// Build argv
// argv[0] = binary path
// argv[1] = "--pg-conn"
// argv[2] = connection string (if provided)
// argv[3] = NULL
char* argv[4];
// Build argv for the caching_relay binary.
// The caching_relay binary uses a JSON config file, not a --pg-conn flag.
// Interface: caching_relay [-c <config.jsonc>] [-d <level>] [-r]
// If a config path is provided, pass it via -c; otherwise let the binary
// use its default (./caching_relay_config.jsonc).
char* argv[5];
int argc = 0;
argv[argc++] = (char*)binary_path;
if (pg_conn && pg_conn[0] != '\0') {
argv[argc++] = (char*)"--pg-conn";
argv[argc++] = (char*)pg_conn;
if (config_path && config_path[0] != '\0') {
argv[argc++] = (char*)"-c";
argv[argc++] = (char*)config_path;
}
argv[argc] = NULL;
@@ -199,10 +200,12 @@ int caching_service_start(void) {
const char* launch_mode = get_config_value("caching_service_launch_mode");
if (!launch_mode || strcmp(launch_mode, "fork") == 0) {
const char* binary_path = get_config_value("caching_service_binary_path");
const char* pg_conn = get_config_value("caching_service_pg_conn");
int rc = caching_service_start_fork(binary_path, pg_conn);
// The caching_relay binary uses a JSON config file (-c <path>), not a
// --pg-conn flag. Read the config path from the config table.
const char* config_path = get_config_value("caching_service_config_path");
int rc = caching_service_start_fork(binary_path, config_path);
if (binary_path) free((char*)binary_path);
if (pg_conn) free((char*)pg_conn);
if (config_path) free((char*)config_path);
if (launch_mode) free((char*)launch_mode);
return rc;
}
+45 -14
View File
@@ -4126,7 +4126,12 @@ int handle_system_command_unified(cJSON* event, const char* command, char* error
return -1;
}
else if (strcmp(command, "caching_status") == 0) {
// Build caching status response
// Build caching status response.
//
// The response data is structured into nested "service" and "inbox"
// objects to match the frontend handler in api/index.js
// (handleCachingStatusResponse). The inbox object also carries the
// detailed poller stats produced by caching_inbox_poller_get_stats().
cJSON* response = cJSON_CreateObject();
cJSON_AddStringToObject(response, "command", "caching_status");
cJSON_AddStringToObject(response, "status", "success");
@@ -4134,32 +4139,57 @@ int handle_system_command_unified(cJSON* event, const char* command, char* error
cJSON* status_data = cJSON_CreateObject();
// Caching config state
// ---- Caching config state (top-level, for config-form hydration) ----
const char* caching_enabled = get_config_value("caching_enabled");
const char* caching_inbox_enabled = get_config_value("caching_inbox_enabled");
const char* caching_config_gen = get_config_value("caching_config_generation");
cJSON_AddStringToObject(status_data, "caching_enabled", caching_enabled ? caching_enabled : "false");
cJSON_AddStringToObject(status_data, "caching_inbox_enabled", caching_inbox_enabled ? caching_inbox_enabled : "false");
cJSON_AddNumberToObject(status_data, "config_generation", caching_config_gen ? atol(caching_config_gen) : 0);
if (caching_enabled) free((char*)caching_enabled);
if (caching_inbox_enabled) free((char*)caching_inbox_enabled);
if (caching_config_gen) free((char*)caching_config_gen);
// Inbox poller stats (PostgreSQL only, no-op on SQLite)
// ---- Inbox poller stats (PostgreSQL only, no-op on SQLite) ----
int total_dequeued = 0, total_accepted = 0, total_rejected = 0;
int total_duplicates = 0, last_batch_size = 0;
int pending_live = 0, pending_backfill = 0, oldest_age = 0;
caching_inbox_poller_get_stats(&total_dequeued, &total_accepted, &total_rejected,
&total_duplicates, &last_batch_size,
&pending_live, &pending_backfill, &oldest_age);
cJSON_AddNumberToObject(status_data, "inbox_total_dequeued", total_dequeued);
cJSON_AddNumberToObject(status_data, "inbox_total_accepted", total_accepted);
cJSON_AddNumberToObject(status_data, "inbox_total_rejected", total_rejected);
cJSON_AddNumberToObject(status_data, "inbox_total_duplicates", total_duplicates);
cJSON_AddNumberToObject(status_data, "inbox_last_batch_size", last_batch_size);
cJSON_AddNumberToObject(status_data, "inbox_pending_live", pending_live);
cJSON_AddNumberToObject(status_data, "inbox_pending_backfill", pending_backfill);
cJSON_AddNumberToObject(status_data, "inbox_oldest_age_seconds", oldest_age);
// ---- service object (external caching service process) ----
cJSON* service_obj = cJSON_CreateObject();
int svc_enabled = caching_enabled && (strcmp(caching_enabled, "true") == 0);
int svc_running = caching_service_is_running();
if (svc_running < 0) svc_running = 0;
cJSON_AddBoolToObject(service_obj, "enabled", svc_enabled ? 1 : 0);
cJSON_AddBoolToObject(service_obj, "running", svc_running ? 1 : 0);
// connected_relays and events_cached are not yet tracked by the
// launcher; emit zeros so the frontend has stable fields to render.
cJSON_AddNumberToObject(service_obj, "connected_relays", 0);
cJSON_AddNumberToObject(service_obj, "events_cached", 0);
cJSON_AddItemToObject(status_data, "service", service_obj);
// ---- inbox object (relay-owned inbox poller) ----
cJSON* inbox_obj = cJSON_CreateObject();
int inbox_enabled = caching_inbox_enabled && (strcmp(caching_inbox_enabled, "true") == 0);
cJSON_AddBoolToObject(inbox_obj, "enabled", inbox_enabled ? 1 : 0);
// The relay-side inbox poller is "running" whenever the relay process
// is up and the inbox consumer is enabled; we report inbox_enabled.
cJSON_AddBoolToObject(inbox_obj, "running", inbox_enabled ? 1 : 0);
cJSON_AddNumberToObject(inbox_obj, "queue_depth", pending_live + pending_backfill);
cJSON_AddNumberToObject(inbox_obj, "last_poll", 0);
cJSON_AddNumberToObject(inbox_obj, "total_dequeued", total_dequeued);
cJSON_AddNumberToObject(inbox_obj, "total_accepted", total_accepted);
cJSON_AddNumberToObject(inbox_obj, "total_rejected", total_rejected);
cJSON_AddNumberToObject(inbox_obj, "total_duplicates", total_duplicates);
cJSON_AddNumberToObject(inbox_obj, "last_batch_size", last_batch_size);
cJSON_AddNumberToObject(inbox_obj, "pending_live", pending_live);
cJSON_AddNumberToObject(inbox_obj, "pending_backfill", pending_backfill);
cJSON_AddNumberToObject(inbox_obj, "oldest_age_seconds", oldest_age);
cJSON_AddItemToObject(status_data, "inbox", inbox_obj);
if (caching_enabled) free((char*)caching_enabled);
if (caching_inbox_enabled) free((char*)caching_inbox_enabled);
if (caching_config_gen) free((char*)caching_config_gen);
// External service state from caching_service_state table (PostgreSQL only)
#ifdef DB_BACKEND_POSTGRES
@@ -4173,6 +4203,7 @@ int handle_system_command_unified(cJSON* event, const char* command, char* error
cJSON_AddItemToObject(response, "data", status_data);
printf("=== Caching Status ===\n");
printf("Service: enabled=%d running=%d\n", svc_enabled, svc_running);
printf("Inbox: dequeued=%d accepted=%d rejected=%d duplicates=%d\n",
total_dequeued, total_accepted, total_rejected, total_duplicates);
printf("Pending: live=%d backfill=%d oldest_age=%ds\n",
+3 -2
View File
@@ -162,8 +162,9 @@ static const struct {
{"caching_inbox_active_poll_ms", "200"}, // Poll interval when inbox has events
{"caching_inbox_idle_poll_ms", "5000"}, // Poll interval when inbox is empty
{"caching_max_event_json_bytes", "65536"}, // Maximum event JSON size for inbox insert
{"caching_service_binary_path", ""}, // Path to caching_relay binary (for fork/exec launch)
{"caching_service_pg_conn", ""}, // PostgreSQL connection string for caching service
{"caching_service_binary_path", "/home/user/lt/caching_relay/caching_relay"}, // Path to caching_relay binary (for fork/exec launch)
{"caching_service_config_path", "/home/user/lt/caching_relay/caching_relay_config.jsonc"}, // Path to caching_relay JSON config file
{"caching_service_pg_conn", "host=localhost port=5432 dbname=crelay user=crelay password=crelay"}, // PostgreSQL connection string for caching service (reserved for future PG-inbox integration)
{"caching_service_launch_mode", "fork"} // Launch mode: "fork" or "systemd" (systemd not yet implemented)
};
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -13,8 +13,8 @@
// Using CRELAY_ prefix to avoid conflicts with nostr_core_lib VERSION macros
#define CRELAY_VERSION_MAJOR 2
#define CRELAY_VERSION_MINOR 1
#define CRELAY_VERSION_PATCH 17
#define CRELAY_VERSION "v2.1.17"
#define CRELAY_VERSION_PATCH 18
#define CRELAY_VERSION "v2.1.18"
// Relay metadata (authoritative source for NIP-11 information)
#define RELAY_NAME "C-Relay-PG"