# Multi-Admin Support Plan ## Overview Enable multiple Nostr pubkeys to act as relay administrators. Currently the relay stores a single `admin_pubkey` string in the `config` table and uses `strcmp()` equality checks throughout the codebase. This plan converts that to a comma-separated list of pubkeys stored in the same config key, with a centralized `is_admin_pubkey()` helper that all authorization gates call. ## Design Principles 1. **Minimal schema change** — keep using the existing `config` table with key `admin_pubkey`. The value changes from a single 64-char hex string to a comma-separated list of 64-char hex strings. 2. **Primary admin concept** — the first pubkey in the list is the "primary admin" (the original one). Only the primary admin can add/remove other admins. This prevents privilege escalation. 3. **Centralized check** — introduce one function `is_admin_pubkey(const char* pubkey)` that all 8 authorization gates call instead of inline `strcmp()`. 4. **In-memory cache** — parse the comma-separated list once on startup/config-change into a static array for O(1)-ish lookups without repeated string parsing. 5. **Backward compatible** — a single pubkey with no commas works identically to today. ## Data Model ``` config table: key = "admin_pubkey" value = "hex1,hex2,hex3" (comma-separated, no spaces) data_type = "string" ``` Maximum admins: 10 (compile-time constant `MAX_ADMIN_PUBKEYS`). ## Architecture ```mermaid flowchart TD A[Config Table: admin_pubkey = hex1,hex2,hex3] --> B[reload_admin_pubkeys_cache] B --> C[Static array: g_admin_pubkeys with count] D[Any authorization check] --> E[is_admin_pubkey - pubkey] E --> C F[CLI: -a flag] --> G[first_time_startup_sequence] G --> A H[Admin command: add_admin / remove_admin] --> I[modify_admin_list] I --> A I --> B ``` ## Implementation Steps ### Step 1: Add centralized admin check infrastructure in config.c / config.h **New constants and types:** ```c #define MAX_ADMIN_PUBKEYS 10 ``` **New functions in config.h:** ```c int is_admin_pubkey(const char* pubkey); // Returns 1 if pubkey is in admin list int get_admin_pubkey_count(void); // Returns number of admins const char* get_primary_admin_pubkey(void); // Returns first admin pubkey int reload_admin_pubkeys_cache(void); // Parse config value into cache int add_admin_pubkey(const char* pubkey); // Add pubkey to admin list (primary admin only) int remove_admin_pubkey(const char* pubkey); // Remove pubkey from admin list (primary admin only) ``` **Implementation in config.c:** - Static array `g_admin_pubkeys[MAX_ADMIN_PUBKEYS][65]` and `g_admin_pubkey_count` - `reload_admin_pubkeys_cache()` reads `admin_pubkey` from config table, splits on commas, validates each is 64 hex chars, populates array - `is_admin_pubkey()` iterates the cached array with `strcmp()` - `get_primary_admin_pubkey()` returns `g_admin_pubkeys[0]` — used where only the primary admin matters (WoT sync, etc.) - `add_admin_pubkey()` / `remove_admin_pubkey()` modify the comma-separated string in the config table and call `reload_admin_pubkeys_cache()` ### Step 2: Replace all 8 authorization strcmp gates Each of these currently does: ```c const char* admin_pubkey = get_config_value("admin_pubkey"); if (strcmp(sender_pubkey, admin_pubkey) == 0) { /* authorized */ } ``` Replace with: ```c if (is_admin_pubkey(sender_pubkey)) { /* authorized */ } ``` **Files and locations:** | File | Function | Line | Change | |------|----------|------|--------| | src/config.c | `process_admin_config_event()` | ~1441 | Replace strcmp with `is_admin_pubkey()` | | src/config.c | `handle_kind_23456_unified()` | ~2727 | Replace strcmp with `is_admin_pubkey()` | | src/main.c | `verify_admin_event()` | ~1905 | Replace strcmp with `is_admin_pubkey()` | | src/main.c | Event storage / WoT trigger | ~1174 | Replace strcmp with `is_admin_pubkey()` | | src/websockets.c | NIP-42 auth bypass | ~2324 | Replace strcmp with `is_admin_pubkey()` | | src/websockets.c | NIP-17 DM admin check | ~3560 | Replace strcmp with `is_admin_pubkey()` | | src/dm_admin.c | `process_admin_dm()` | ~503 | Replace strcmp with `is_admin_pubkey()` | | src/request_validator.c | Event validation bypass | ~307 | Replace strcmp with `is_admin_pubkey()` | Each replacement also eliminates the `get_config_value("admin_pubkey")` call and its associated `free()`, simplifying the code. ### Step 3: Update startup / initialization pipeline **config.c `first_time_startup_sequence()`:** - No change needed — it already stores a single pubkey. The comma-separated format with one entry is identical to the current format. **config.c `populate_all_config_values_atomic()`:** - No change needed — inserts the initial admin_pubkey as a single value. **config.c `add_pubkeys_to_config_table()`:** - No change needed — stores single pubkey on migration. **config.c `apply_cli_overrides_atomic()`:** - No change needed — CLI override replaces the entire admin_pubkey value. **main.c startup validation:** - Update the pre-warm validation at ~line 2438 to call `reload_admin_pubkeys_cache()` and validate that at least one admin pubkey exists, rather than checking for exactly 64 chars. **Call `reload_admin_pubkeys_cache()`** after: - `populate_all_config_values_atomic()` completes - `reload_config_from_table()` completes - Any admin add/remove operation ### Step 4: Add admin management commands to kind 23456 API Add two new admin commands that can be sent as encrypted kind 23456 events: ```json ["add_admin", "pubkey_hex"] ["remove_admin", "pubkey_hex"] ["list_admins"] ``` **Authorization rule:** Only the primary admin (first in list) can add/remove other admins. This is enforced in the command handler, not in the general `is_admin_pubkey()` check. **Implementation location:** `handle_system_command_unified()` in config.c, alongside existing commands like `system_status`, `graceful_shutdown`, etc. ### Step 5: Update WoT sync to handle multiple admins In `wot_sync_from_admin_kind3()`: - Currently queries kind 3 events from the single admin pubkey - Change to use `get_primary_admin_pubkey()` — WoT trust anchor should remain the primary admin - This is a deliberate design choice: secondary admins can manage the relay but the WoT trust graph is rooted in the primary admin's social graph ### Step 6: Update CLI to accept multiple admin pubkeys **Option A - Comma-separated single flag (recommended):** ```bash ./c_relay_pg -a npub1...,npub2...,npub3... ``` **Changes:** - `cli_options_t.admin_pubkey_override` becomes `char admin_pubkeys_override[MAX_ADMIN_PUBKEYS * 65]` (larger buffer for comma-separated list) - CLI parsing in main.c splits on commas, decodes each npub/hex, reassembles as comma-separated hex - `make_and_restart_relay.sh` `-a` flag documentation updated ### Step 7: Update config validation in api.c In the config validation table at line ~912: ```c {"admin_pubkey", "string", 0, 65}, ``` Change max length to accommodate multiple pubkeys: ```c {"admin_pubkey", "string", 0, MAX_ADMIN_PUBKEYS * 65}, ``` ### Step 8: Update get_admin_pubkey_cached `get_admin_pubkey_cached()` currently returns the raw config value. Update to return only the primary admin pubkey for backward compatibility with any callers that expect a single 64-char string. ### Step 9: Update test scripts Update test scripts that hardcode admin keys to work with the multi-admin system: - `tests/sql_test.sh` - `tests/white_black_test.sh` - `tests/17_nip_test.sh` - `tests/run_all_tests.sh` - `tests/.test_keys.txt` Add new test cases for: - Adding a second admin via kind 23456 command - Verifying second admin can send admin commands - Verifying non-admin cannot send admin commands - Removing a secondary admin - Verifying primary admin cannot be removed - Verifying secondary admin cannot add/remove admins ### Step 10: Update documentation - `AGENTS.md` — update admin API event structure section - `docs/configuration_guide.md` — document multi-admin setup - `docs/user_guide.md` — document admin management commands - `API.md` — document new add_admin/remove_admin/list_admins commands - `README.md` — mention multi-admin capability ## Migration / Backward Compatibility - Existing databases with a single `admin_pubkey` value work without any migration — a string with no commas is treated as a single-admin list - The `is_admin_pubkey()` function handles both formats transparently - No schema changes needed — the config table already stores strings of arbitrary length ## Security Considerations 1. **Primary admin privilege** — only the first pubkey in the list can add/remove admins, preventing secondary admins from locking out the primary 2. **No self-removal** — the primary admin cannot remove themselves (prevents accidental lockout) 3. **Max admin limit** — capped at 10 to prevent abuse and keep the linear scan fast 4. **Validation** — each pubkey in the list is validated as 64 hex characters before storage