# 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
