8.9 KiB
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
- Minimal schema change — keep using the existing
configtable with keyadmin_pubkey. The value changes from a single 64-char hex string to a comma-separated list of 64-char hex strings. - 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.
- Centralized check — introduce one function
is_admin_pubkey(const char* pubkey)that all 8 authorization gates call instead of inlinestrcmp(). - 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.
- 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
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:
#define MAX_ADMIN_PUBKEYS 10
New functions in config.h:
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]andg_admin_pubkey_count reload_admin_pubkeys_cache()readsadmin_pubkeyfrom config table, splits on commas, validates each is 64 hex chars, populates arrayis_admin_pubkey()iterates the cached array withstrcmp()get_primary_admin_pubkey()returnsg_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 callreload_admin_pubkeys_cache()
Step 2: Replace all 8 authorization strcmp gates
Each of these currently does:
const char* admin_pubkey = get_config_value("admin_pubkey");
if (strcmp(sender_pubkey, admin_pubkey) == 0) { /* authorized */ }
Replace with:
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()completesreload_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:
["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):
./c_relay_pg -a npub1...,npub2...,npub3...
Changes:
cli_options_t.admin_pubkey_overridebecomeschar 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-aflag documentation updated
Step 7: Update config validation in api.c
In the config validation table at line ~912:
{"admin_pubkey", "string", 0, 65},
Change max length to accommodate multiple pubkeys:
{"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.shtests/white_black_test.shtests/17_nip_test.shtests/run_all_tests.shtests/.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 sectiondocs/configuration_guide.md— document multi-admin setupdocs/user_guide.md— document admin management commandsAPI.md— document new add_admin/remove_admin/list_admins commandsREADME.md— mention multi-admin capability
Migration / Backward Compatibility
- Existing databases with a single
admin_pubkeyvalue 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
- Primary admin privilege — only the first pubkey in the list can add/remove admins, preventing secondary admins from locking out the primary
- No self-removal — the primary admin cannot remove themselves (prevents accidental lockout)
- Max admin limit — capped at 10 to prevent abuse and keep the linear scan fast
- Validation — each pubkey in the list is validated as 64 hex characters before storage