Files
c-relay-pg/plans/multi_admin_plan.md
T

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

  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

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] 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:

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() 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:

["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_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:

{"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.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