Skip to main content

nostr_bbs_core/
admin_shared.rs

1//! Shared admin-check primitives consumed by all worker crates.
2//!
3//! Workers are separate `cdylib` WASM targets and cannot import functions from
4//! each other. This module provides the **canonical** SQL query strings,
5//! deserialization types, and documentation of the admin-check algorithm so
6//! every worker implements the same logic without structural drift.
7//!
8//! ## Canonical admin-check algorithm
9//!
10//! A pubkey is considered an admin if **either** of these D1 queries returns
11//! `is_admin = 1`:
12//!
13//! 1. `SELECT is_admin FROM whitelist WHERE pubkey = ?1` (RELAY_DB binding —
14//!    the relay worker's whitelist, source of truth for admin flags written by
15//!    the `/api/whitelist/*` management handlers).
16//!
17//! 2. `SELECT is_admin FROM members WHERE pubkey = ?1` (DB / REPLAY_DB binding
18//!    — the auth worker's members table, populated by the invite-redemption
19//!    flow).
20//!
21//! On DB error or missing rows, the check returns `false` — never leaking
22//! ambient authority across error branches.
23//!
24//! ## Static admin bootstrap (`ADMIN_PUBKEYS`)
25//!
26//! In addition to the two D1 sources, a deploy-time **static** admin set may be
27//! injected via the `ADMIN_PUBKEYS` env var (comma-separated hex pubkeys). This
28//! mirrors `forum.toml [admin] static_pubkeys` and is the bootstrap/fallback
29//! authority: a fresh deployment whose D1 `whitelist`/`members` tables carry no
30//! `is_admin = 1` row still has working admins so the operator can seed D1.
31//!
32//! The canonical resolution order is **`ADMIN_PUBKEYS` (static) ∪ D1**. Every
33//! worker parses the env var through [`admin_pubkeys_from_env_str`] so the
34//! comma/whitespace/empty-filter semantics never drift between crates (workers
35//! are separate WASM targets and cannot share a function at link time).
36//!
37//! ## Listing all admin pubkeys
38//!
39//! To list all admins (e.g. for the `/api/admins` endpoint or KV cache
40//! population), query both tables:
41//!
42//! - `SELECT pubkey FROM whitelist WHERE is_admin = 1`
43//! - `SELECT pubkey FROM members WHERE is_admin = 1`
44//!
45//! Deduplicate the union.
46
47use serde::Deserialize;
48
49/// Row type for single-pubkey admin checks.
50///
51/// Used with [`WHITELIST_IS_ADMIN_SQL`] and [`MEMBERS_IS_ADMIN_SQL`].
52/// The `is_admin` column is `INTEGER` in D1 (SQLite); `1` = admin, `0` = not.
53#[derive(Debug, Clone, Deserialize)]
54pub struct IsAdminRow {
55    pub is_admin: i32,
56}
57
58/// Row type for listing admin pubkeys.
59///
60/// Used with [`WHITELIST_ADMIN_LIST_SQL`] and [`MEMBERS_ADMIN_LIST_SQL`].
61#[derive(Debug, Clone, Deserialize)]
62pub struct PubkeyRow {
63    pub pubkey: String,
64}
65
66// ---------------------------------------------------------------------------
67// Canonical SQL query strings — use these in every worker.
68// ---------------------------------------------------------------------------
69
70/// Check if a pubkey is admin in the relay's whitelist table.
71/// Bind parameter: `?1` = pubkey (hex string).
72pub const WHITELIST_IS_ADMIN_SQL: &str = "SELECT is_admin FROM whitelist WHERE pubkey = ?1";
73
74/// Check if a pubkey is admin in the auth/members table.
75/// Bind parameter: `?1` = pubkey (hex string).
76pub const MEMBERS_IS_ADMIN_SQL: &str = "SELECT is_admin FROM members WHERE pubkey = ?1";
77
78/// List all admin pubkeys from the relay's whitelist table.
79pub const WHITELIST_ADMIN_LIST_SQL: &str = "SELECT pubkey FROM whitelist WHERE is_admin = 1";
80
81/// List all admin pubkeys from the auth/members table.
82pub const MEMBERS_ADMIN_LIST_SQL: &str = "SELECT pubkey FROM members WHERE is_admin = 1";
83
84// ---------------------------------------------------------------------------
85// Static admin set (`ADMIN_PUBKEYS` env var)
86// ---------------------------------------------------------------------------
87
88/// Env var carrying the deploy-time static admin set, comma-separated hex
89/// pubkeys. Mirrors `forum.toml [admin] static_pubkeys` (injected at deploy
90/// time, following the `POD_BASE_URL` mirroring convention).
91pub const ADMIN_PUBKEYS_VAR: &str = "ADMIN_PUBKEYS";
92
93/// Parse a raw `ADMIN_PUBKEYS` value into the static admin pubkey set.
94///
95/// Splits on `,`, trims each entry, and drops empties — so an unset/empty var
96/// yields an empty `Vec` (no ambient admins), and trailing commas or stray
97/// whitespace are tolerated. This is the single canonical parser; every worker
98/// calls it so the comma/whitespace/empty semantics stay identical across the
99/// separately-compiled WASM targets.
100pub fn admin_pubkeys_from_env_str(raw: &str) -> Vec<String> {
101    raw.split(',')
102        .map(|k| k.trim().to_string())
103        .filter(|k| !k.is_empty())
104        .collect()
105}
106
107/// Whether `pubkey` is present in the static `ADMIN_PUBKEYS` set parsed from
108/// `raw`. Constant-shape membership test over [`admin_pubkeys_from_env_str`].
109pub fn is_static_admin(pubkey: &str, raw: &str) -> bool {
110    admin_pubkeys_from_env_str(raw).iter().any(|k| k == pubkey)
111}
112
113// ---------------------------------------------------------------------------
114// Tests
115// ---------------------------------------------------------------------------
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120
121    #[test]
122    fn is_admin_row_deserialises() {
123        let json = r#"{"is_admin": 1}"#;
124        let row: IsAdminRow = serde_json::from_str(json).unwrap();
125        assert_eq!(row.is_admin, 1);
126    }
127
128    #[test]
129    fn is_admin_row_deserialises_zero() {
130        let json = r#"{"is_admin": 0}"#;
131        let row: IsAdminRow = serde_json::from_str(json).unwrap();
132        assert_eq!(row.is_admin, 0);
133    }
134
135    #[test]
136    fn pubkey_row_deserialises() {
137        let json = r#"{"pubkey": "aabbccdd"}"#;
138        let row: PubkeyRow = serde_json::from_str(json).unwrap();
139        assert_eq!(row.pubkey, "aabbccdd");
140    }
141
142    #[test]
143    fn sql_constants_are_stable() {
144        assert!(WHITELIST_IS_ADMIN_SQL.contains("?1"));
145        assert!(MEMBERS_IS_ADMIN_SQL.contains("?1"));
146        assert!(WHITELIST_ADMIN_LIST_SQL.contains("is_admin = 1"));
147        assert!(MEMBERS_ADMIN_LIST_SQL.contains("is_admin = 1"));
148    }
149
150    #[test]
151    fn static_admin_parse_empty_is_no_admins() {
152        assert!(admin_pubkeys_from_env_str("").is_empty());
153        assert!(admin_pubkeys_from_env_str("   ").is_empty());
154        assert!(admin_pubkeys_from_env_str(",, ,").is_empty());
155    }
156
157    #[test]
158    fn static_admin_parse_trims_and_filters() {
159        let keys = admin_pubkeys_from_env_str(" aabb , ccdd ,,eeff ");
160        assert_eq!(keys, vec!["aabb", "ccdd", "eeff"]);
161    }
162
163    #[test]
164    fn static_admin_membership() {
165        let raw = "6407eed8,5d80b5fa";
166        assert!(is_static_admin("6407eed8", raw));
167        assert!(is_static_admin("5d80b5fa", raw));
168        assert!(!is_static_admin("deadbeef", raw));
169        assert!(!is_static_admin("6407eed8", ""));
170    }
171}