feat: self-service token minting via NIP-98 HTTP Auth (#37)

This commit is contained in:
tlongwell-block
2026-03-12 10:11:56 -04:00
committed by GitHub
parent 14817123fa
commit f84da74d3f
32 changed files with 3294 additions and 221 deletions

52
Cargo.lock generated
View File

@@ -604,6 +604,24 @@ dependencies = [
"once_cell",
]
[[package]]
name = "crossbeam-channel"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-epoch"
version = "0.9.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-queue"
version = "0.3.12"
@@ -1593,6 +1611,23 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "moka"
version = "0.12.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85f8024e1c8e71c778968af91d43700ce1d11b219d127d79fb2934153b82b42b"
dependencies = [
"crossbeam-channel",
"crossbeam-epoch",
"crossbeam-utils",
"equivalent",
"parking_lot",
"portable-atomic",
"smallvec",
"tagptr",
"uuid",
]
[[package]]
name = "negentropy"
version = "0.3.1"
@@ -1883,6 +1918,12 @@ dependencies = [
"universal-hash",
]
[[package]]
name = "portable-atomic"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
[[package]]
name = "potential_utf"
version = "0.1.4"
@@ -2803,6 +2844,7 @@ dependencies = [
"deadpool-redis",
"futures-util",
"hex",
"moka",
"nostr",
"redis",
"serde",
@@ -2849,11 +2891,15 @@ name = "sprout-test-client"
version = "0.1.0"
dependencies = [
"anyhow",
"base64",
"chrono",
"futures-util",
"hex",
"nostr",
"reqwest",
"serde",
"serde_json",
"sha2",
"sprout-core",
"sprout-mcp",
"thiserror",
@@ -3143,6 +3189,12 @@ dependencies = [
"syn",
]
[[package]]
name = "tagptr"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417"
[[package]]
name = "thiserror"
version = "2.0.18"

View File

@@ -78,6 +78,7 @@ rand = "0.8"
# Concurrent data structures
dashmap = "6"
moka = { version = "0.12", features = ["sync"] }
# JWT validation (Okta JWKS)
jsonwebtoken = "9"

View File

@@ -34,6 +34,27 @@ pub enum AuthError {
#[error("api token invalid or expired")]
TokenInvalid,
/// The API token was found in the database but has been revoked.
///
/// Distinct from [`TokenInvalid`] so the relay can return `401 token_revoked`
/// rather than the generic `invalid_token` error code.
#[error("api token has been revoked")]
TokenRevoked,
/// The API token was found in the database but has passed its expiry timestamp.
///
/// Distinct from [`TokenInvalid`] so the relay can return `401 token_expired`
/// rather than the generic `invalid_token` error code.
#[error("api token has expired")]
TokenExpired,
/// NIP-98 HTTP Auth event (kind:27235) failed verification.
///
/// The inner string describes the specific failure (signature, timestamp, URL, etc.)
/// and is safe to include in server logs. Do **not** forward raw event content to clients.
#[error("NIP-98 HTTP Auth verification failed: {0}")]
Nip98Invalid(String),
/// The pubkey in the NIP-42 event does not match the identity in the JWT or API token.
#[error("pubkey mismatch: event pubkey does not match authenticated identity")]
PubkeyMismatch,

View File

@@ -21,6 +21,8 @@ pub mod access;
pub mod error;
/// NIP-42 challenge–response authentication.
pub mod nip42;
/// NIP-98 HTTP Auth verification (kind:27235).
pub mod nip98;
/// Okta OIDC integration and JWKS validation.
pub mod okta;
/// Per-connection rate limiting.
@@ -33,11 +35,12 @@ pub mod token;
pub use access::{check_read_access, check_write_access, require_scope, ChannelAccessChecker};
pub use error::AuthError;
pub use nip42::{generate_challenge, verify_nip42_event};
pub use nip98::verify_nip98_event;
pub use okta::{CachedJwks, Jwks, JwksCache, OktaConfig};
pub use rate_limit::{
ip_rate_limit_key, rate_limit_key, LimitType, RateLimitConfig, RateLimitResult, RateLimiter,
};
pub use scope::{parse_scopes, Scope};
pub use scope::{is_self_mintable, parse_scopes, Scope, SELF_MINTABLE_SCOPES};
pub use token::{generate_token, hash_token, verify_token_hash};
#[cfg(any(test, feature = "test-utils"))]

View File

@@ -0,0 +1,326 @@
//! NIP-98 HTTP Auth verification (kind:27235).
//!
//! NIP-98 is the standard Nostr HTTP Auth pattern used by Nostr.build, Blossom, and
//! other Nostr HTTP services. It is **stateless** — no WebSocket session required.
//!
//! The client signs a short-lived kind:27235 event containing the target URL, HTTP method,
//! and an optional SHA-256 hash of the request body, then sends it as:
//!
//! ```text
//! Authorization: Nostr <base64(JSON-serialized-event)>
//! ```
//!
//! ## Verification steps
//!
//! 1. Parse JSON into a `nostr::Event`
//! 2. Verify `kind == 27235` (`Kind::HttpAuth`)
//! 3. Verify Schnorr signature via `sprout_core::verify_event`
//! 4. Verify `created_at` within ±60 seconds of server time
//! 5. Verify `["u", <url>]` tag matches `expected_url` (normalised: case-insensitive
//! scheme/host, trailing slash stripped)
//! 6. Verify `["method", <method>]` tag matches `expected_method` (case-insensitive)
//! 7. If `["payload", <hash>]` tag is present **and** `body` is `Some`: verify
//! `SHA-256(body) == hex(payload_tag)`. This prevents body-substitution attacks.
//! 8. Return `event.pubkey` on success.
use nostr::{Alphabet, Event, Kind, SingleLetterTag, TagKind, Timestamp};
use sha2::{Digest, Sha256};
use url::Url;
use crate::error::AuthError;
const TIMESTAMP_TOLERANCE_SECS: u64 = 60;
/// Verify a NIP-98 HTTP Auth event (kind:27235).
///
/// # Parameters
///
/// - `event_json` — the raw JSON string of the Nostr event (decoded from base64 by the caller).
/// - `expected_url` — the canonical URL of the request being authenticated.
/// For reverse-proxy deployments, reconstruct from `X-Forwarded-Proto` / `X-Forwarded-Host`
/// before passing here.
/// - `expected_method` — the HTTP method (e.g. `"POST"`). Compared case-insensitively.
/// - `body` — raw request body bytes. If `Some` and a `payload` tag is present in the event,
/// the SHA-256 hash of `body` must match the tag value. If `None`, the `payload` tag is
/// ignored (clients SHOULD include it for POST requests, but it is not required).
///
/// # Returns
///
/// The authenticated `nostr::PublicKey` on success.
///
/// # Errors
///
/// Returns [`AuthError::Nip98Invalid`] with a descriptive message for any verification failure.
/// The message is safe for server logs but should not be forwarded verbatim to clients.
pub fn verify_nip98_event(
event_json: &str,
expected_url: &str,
expected_method: &str,
body: Option<&[u8]>,
) -> Result<nostr::PublicKey, AuthError> {
// 1. Parse JSON.
let event: Event = serde_json::from_str(event_json)
.map_err(|e| AuthError::Nip98Invalid(format!("event JSON parse error: {e}")))?;
// 2. Verify kind == 27235.
if event.kind != Kind::HttpAuth {
return Err(AuthError::Nip98Invalid(format!(
"expected kind 27235, got {}",
event.kind.as_u16()
)));
}
// 3. Verify Schnorr signature (also verifies event ID hash).
sprout_core::verify_event(&event)
.map_err(|_| AuthError::Nip98Invalid("invalid Schnorr signature".to_string()))?;
// 4. Verify created_at within ±60 seconds of now.
let now = Timestamp::now().as_u64();
let event_ts = event.created_at.as_u64();
let delta = now.abs_diff(event_ts);
if delta > TIMESTAMP_TOLERANCE_SECS {
return Err(AuthError::Nip98Invalid(format!(
"event timestamp outside ±{TIMESTAMP_TOLERANCE_SECS}s window (delta: {delta}s)"
)));
}
// 5. Verify `u` tag matches expected_url (normalised).
// NIP-98 uses the single-letter "u" tag, not the multi-letter "url" tag.
let u_tag = event
.tags
.find(TagKind::SingleLetter(SingleLetterTag::lowercase(
Alphabet::U,
)))
.and_then(|t| t.content())
.ok_or_else(|| AuthError::Nip98Invalid("missing `u` tag".to_string()))?;
if normalize_url(u_tag) != normalize_url(expected_url) {
return Err(AuthError::Nip98Invalid(format!(
"URL mismatch: event has `{u_tag}`, expected `{expected_url}`"
)));
}
// 6. Verify `method` tag matches expected_method (case-insensitive).
let method_tag = event
.tags
.find(TagKind::Method)
.and_then(|t| t.content())
.ok_or_else(|| AuthError::Nip98Invalid("missing `method` tag".to_string()))?;
if !method_tag.eq_ignore_ascii_case(expected_method) {
return Err(AuthError::Nip98Invalid(format!(
"method mismatch: event has `{method_tag}`, expected `{expected_method}`"
)));
}
// 7. If `payload` tag present AND body is Some: verify SHA-256(body) == payload hex.
let payload_tag = event.tags.find(TagKind::Payload).and_then(|t| t.content());
if let (Some(payload_hex), Some(body_bytes)) = (payload_tag, body) {
let computed: [u8; 32] = Sha256::digest(body_bytes).into();
let computed_hex = hex::encode(computed);
if computed_hex != payload_hex {
return Err(AuthError::Nip98Invalid(
"payload tag SHA-256 mismatch: request body does not match signed hash".to_string(),
));
}
}
// 8. Return the authenticated pubkey.
Ok(event.pubkey)
}
/// Normalize a URL for comparison.
///
/// - Lowercases scheme and host (already done by the `url` crate).
/// - Strips trailing slash from path.
/// - Treats `localhost` and `::1` as equivalent to `127.0.0.1`.
fn normalize_url(raw: &str) -> String {
let mut parsed = match Url::parse(raw) {
Ok(u) => u,
Err(_) => return raw.to_lowercase(),
};
if let Some(host) = parsed.host_str() {
if host == "localhost" || host == "::1" {
let _ = parsed.set_host(Some("127.0.0.1"));
}
}
let path = parsed.path().trim_end_matches('/').to_string();
parsed.set_path(&path);
parsed.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use nostr::{EventBuilder, Keys, Kind, Timestamp};
const TEST_URL: &str = "https://relay.example.com/api/tokens";
const TEST_METHOD: &str = "POST";
fn make_nip98_event(
keys: &Keys,
url: &str,
method: &str,
payload_hex: Option<&str>,
created_at: Option<Timestamp>,
) -> String {
use nostr::Tag;
let mut tags = vec![
Tag::parse(&["u", url]).unwrap(),
Tag::parse(&["method", method]).unwrap(),
];
if let Some(hex) = payload_hex {
tags.push(Tag::parse(&["payload", hex]).unwrap());
}
let mut builder = EventBuilder::new(Kind::HttpAuth, "", tags);
if let Some(ts) = created_at {
builder = builder.custom_created_at(ts);
}
let event = builder.sign_with_keys(keys).expect("sign");
serde_json::to_string(&event).expect("serialize")
}
#[test]
fn valid_event_returns_pubkey() {
let keys = Keys::generate();
let json = make_nip98_event(&keys, TEST_URL, TEST_METHOD, None, None);
let result = verify_nip98_event(&json, TEST_URL, TEST_METHOD, None);
assert!(result.is_ok(), "verify failed: {:?}", result.err());
assert_eq!(result.unwrap(), keys.public_key());
}
#[test]
fn wrong_kind_rejected() {
let keys = Keys::generate();
let event = EventBuilder::new(Kind::TextNote, "", [])
.sign_with_keys(&keys)
.expect("sign");
let json = serde_json::to_string(&event).unwrap();
let result = verify_nip98_event(&json, TEST_URL, TEST_METHOD, None);
assert!(matches!(result, Err(AuthError::Nip98Invalid(_))));
}
#[test]
fn expired_timestamp_rejected() {
let keys = Keys::generate();
let old_ts = Timestamp::from(Timestamp::now().as_u64().saturating_sub(120));
let json = make_nip98_event(&keys, TEST_URL, TEST_METHOD, None, Some(old_ts));
let result = verify_nip98_event(&json, TEST_URL, TEST_METHOD, None);
assert!(matches!(result, Err(AuthError::Nip98Invalid(_))));
}
#[test]
fn url_mismatch_rejected() {
let keys = Keys::generate();
let json = make_nip98_event(
&keys,
"https://other.example.com/api/tokens",
TEST_METHOD,
None,
None,
);
let result = verify_nip98_event(&json, TEST_URL, TEST_METHOD, None);
assert!(matches!(result, Err(AuthError::Nip98Invalid(_))));
}
#[test]
fn method_mismatch_rejected() {
let keys = Keys::generate();
let json = make_nip98_event(&keys, TEST_URL, "GET", None, None);
let result = verify_nip98_event(&json, TEST_URL, TEST_METHOD, None);
assert!(matches!(result, Err(AuthError::Nip98Invalid(_))));
}
#[test]
fn method_case_insensitive() {
let keys = Keys::generate();
let json = make_nip98_event(&keys, TEST_URL, "post", None, None);
let result = verify_nip98_event(&json, TEST_URL, "POST", None);
assert!(result.is_ok());
}
#[test]
fn payload_tag_correct_hash_passes() {
let keys = Keys::generate();
let body = b"hello world";
let hash: [u8; 32] = Sha256::digest(body).into();
let hash_hex = hex::encode(hash);
let json = make_nip98_event(&keys, TEST_URL, TEST_METHOD, Some(&hash_hex), None);
let result = verify_nip98_event(&json, TEST_URL, TEST_METHOD, Some(body));
assert!(result.is_ok());
}
#[test]
fn payload_tag_wrong_hash_rejected() {
let keys = Keys::generate();
let body = b"hello world";
let wrong_hex = "deadbeef".repeat(8); // 64 hex chars but wrong hash
let json = make_nip98_event(&keys, TEST_URL, TEST_METHOD, Some(&wrong_hex), None);
let result = verify_nip98_event(&json, TEST_URL, TEST_METHOD, Some(body));
assert!(matches!(result, Err(AuthError::Nip98Invalid(_))));
}
#[test]
fn payload_tag_absent_with_body_passes() {
// payload tag is optional per spec; clients SHOULD include it but it's not required
let keys = Keys::generate();
let json = make_nip98_event(&keys, TEST_URL, TEST_METHOD, None, None);
let result = verify_nip98_event(&json, TEST_URL, TEST_METHOD, Some(b"some body"));
assert!(result.is_ok());
}
#[test]
fn trailing_slash_normalized() {
let keys = Keys::generate();
let url_with_slash = "https://relay.example.com/api/tokens/";
let json = make_nip98_event(&keys, url_with_slash, TEST_METHOD, None, None);
// expected_url without trailing slash — should still match
let result = verify_nip98_event(&json, TEST_URL, TEST_METHOD, None);
assert!(result.is_ok());
}
#[test]
fn localhost_normalized() {
let keys = Keys::generate();
let localhost_url = "http://localhost:3000/api/tokens";
let loopback_url = "http://127.0.0.1:3000/api/tokens";
let json = make_nip98_event(&keys, localhost_url, TEST_METHOD, None, None);
let result = verify_nip98_event(&json, loopback_url, TEST_METHOD, None);
assert!(result.is_ok());
}
#[test]
fn is_self_mintable_all_seven() {
use crate::scope::{is_self_mintable, Scope};
assert!(is_self_mintable(&Scope::MessagesRead));
assert!(is_self_mintable(&Scope::MessagesWrite));
assert!(is_self_mintable(&Scope::ChannelsRead));
assert!(is_self_mintable(&Scope::ChannelsWrite));
assert!(is_self_mintable(&Scope::UsersRead));
assert!(is_self_mintable(&Scope::FilesRead));
assert!(is_self_mintable(&Scope::FilesWrite));
}
#[test]
fn is_self_mintable_admin_scopes_false() {
use crate::scope::{is_self_mintable, Scope};
assert!(!is_self_mintable(&Scope::AdminChannels));
assert!(!is_self_mintable(&Scope::UsersWrite));
assert!(!is_self_mintable(&Scope::AdminUsers));
assert!(!is_self_mintable(&Scope::JobsRead));
assert!(!is_self_mintable(&Scope::JobsWrite));
assert!(!is_self_mintable(&Scope::SubscriptionsRead));
assert!(!is_self_mintable(&Scope::SubscriptionsWrite));
}
#[test]
fn is_self_mintable_unknown_false() {
use crate::scope::{is_self_mintable, Scope};
assert!(!is_self_mintable(&Scope::Unknown(
"future:scope".to_string()
)));
}
}

View File

@@ -47,6 +47,51 @@ pub enum Scope {
}
impl Scope {
/// Return a `Vec` containing every known scope variant.
///
/// Used in dev mode (`require_auth_token=false`) where `X-Pubkey` header
/// auth grants unrestricted access — there is no token to derive scopes from.
pub fn all_known() -> Vec<Scope> {
vec![
Self::MessagesRead,
Self::MessagesWrite,
Self::ChannelsRead,
Self::ChannelsWrite,
Self::AdminChannels,
Self::UsersRead,
Self::UsersWrite,
Self::AdminUsers,
Self::JobsRead,
Self::JobsWrite,
Self::SubscriptionsRead,
Self::SubscriptionsWrite,
Self::FilesRead,
Self::FilesWrite,
]
}
/// Return a `Vec` containing every known scope variant except admin scopes.
///
/// Used in dev mode (`require_auth_token=false`) where `X-Pubkey` header auth grants
/// access without a real token. Admin operations (`AdminChannels`, `AdminUsers`) require
/// a real token even in dev mode, so they are excluded here.
pub fn all_non_admin() -> Vec<Scope> {
vec![
Self::MessagesRead,
Self::MessagesWrite,
Self::ChannelsRead,
Self::ChannelsWrite,
Self::UsersRead,
Self::UsersWrite,
Self::JobsRead,
Self::JobsWrite,
Self::SubscriptionsRead,
Self::SubscriptionsWrite,
Self::FilesRead,
Self::FilesWrite,
]
}
/// Return the canonical wire-format string for this scope (e.g. `"messages:read"`).
pub fn as_str(&self) -> &str {
match self {
@@ -99,6 +144,39 @@ impl FromStr for Scope {
}
}
/// Scopes that can be self-minted via `POST /api/tokens`.
///
/// Admin-only scopes (`AdminChannels`, `UsersWrite`, `AdminUsers`, `JobsRead`, `JobsWrite`,
/// `SubscriptionsRead`, `SubscriptionsWrite`) are intentionally excluded — they require
/// `sprout-admin mint-token`.
pub const SELF_MINTABLE_SCOPES: &[Scope] = &[
Scope::MessagesRead,
Scope::MessagesWrite,
Scope::ChannelsRead,
Scope::ChannelsWrite,
Scope::UsersRead,
Scope::FilesRead,
Scope::FilesWrite,
];
/// Returns `true` if the given scope may be requested via `POST /api/tokens`.
///
/// Admin-only scopes and `Scope::Unknown` always return `false`.
/// Unknown scope strings are rejected at mint time rather than silently accepted —
/// a client sending an unrecognised scope string likely has a bug.
pub fn is_self_mintable(scope: &Scope) -> bool {
matches!(
scope,
Scope::MessagesRead
| Scope::MessagesWrite
| Scope::ChannelsRead
| Scope::ChannelsWrite
| Scope::UsersRead
| Scope::FilesRead
| Scope::FilesWrite
)
}
/// Parse a slice of scope strings into `Vec<Scope>`.
pub fn parse_scopes(raw: &[impl AsRef<str>]) -> Vec<Scope> {
raw.iter()
@@ -134,4 +212,49 @@ mod tests {
let scopes = parse_scopes(&["messages:read", "channels:write"]);
assert_eq!(scopes, vec![Scope::MessagesRead, Scope::ChannelsWrite]);
}
#[test]
fn all_non_admin_excludes_admin_scopes() {
let scopes = Scope::all_non_admin();
assert_eq!(scopes.len(), 12, "expected 12 non-admin scope variants");
// Verify no duplicates
let unique: std::collections::HashSet<_> = scopes.iter().map(|s| s.as_str()).collect();
assert_eq!(
unique.len(),
12,
"all_non_admin() must not contain duplicates"
);
// Verify no Unknown variants
for scope in &scopes {
assert!(
!matches!(scope, Scope::Unknown(_)),
"all_non_admin() must not contain Unknown variants"
);
}
// Verify admin scopes are excluded
assert!(
!scopes.contains(&Scope::AdminChannels),
"all_non_admin() must not contain AdminChannels"
);
assert!(
!scopes.contains(&Scope::AdminUsers),
"all_non_admin() must not contain AdminUsers"
);
}
#[test]
fn all_known_returns_all_14_variants() {
let all = Scope::all_known();
assert_eq!(all.len(), 14, "expected 14 known scope variants");
// Verify no duplicates
let unique: std::collections::HashSet<_> = all.iter().map(|s| s.as_str()).collect();
assert_eq!(unique.len(), 14, "all_known() must not contain duplicates");
// Verify no Unknown variants
for scope in &all {
assert!(
!matches!(scope, Scope::Unknown(_)),
"all_known() must not contain Unknown variants"
);
}
}
}

View File

@@ -1,10 +1,11 @@
//! API token CRUD operations.
use chrono::{DateTime, Utc};
use sqlx::MySqlPool;
use sqlx::{MySqlPool, Row};
use uuid::Uuid;
use crate::error::{DbError, Result};
use crate::event::uuid_from_bytes;
/// Create a new API token record. The caller is responsible for generating
/// the raw token and computing its SHA-256 hash.
@@ -49,3 +50,248 @@ pub async fn create_api_token(
Ok(id)
}
/// Atomic conditional INSERT: create a token only if the owner has fewer than 10 active tokens.
///
/// Uses a `SELECT ... WHERE COUNT < 10` subquery so the check and insert are atomic —
/// no TOCTOU race between a separate count query and the insert.
///
/// Returns `Ok(Some(uuid))` on success, `Ok(None)` if the 10-token limit is exceeded.
pub async fn create_api_token_if_under_limit(
pool: &MySqlPool,
token_hash: &[u8],
owner_pubkey: &[u8],
name: &str,
scopes: &[String],
channel_ids: Option<&[Uuid]>,
expires_at: Option<DateTime<Utc>>,
) -> Result<Option<Uuid>> {
let id = Uuid::new_v4();
let id_bytes = id.as_bytes().as_slice();
let scopes_json =
serde_json::to_value(scopes).map_err(|e| DbError::InvalidData(e.to_string()))?;
let channel_ids_json: Option<serde_json::Value> = channel_ids
.map(|ids| {
serde_json::to_value(ids.iter().map(|id| id.to_string()).collect::<Vec<_>>())
.map_err(|e| DbError::InvalidData(format!("channel_ids serialization: {e}")))
})
.transpose()?;
// Conditional INSERT: only inserts if active (non-revoked, non-expired) token count < 10.
// The subquery and insert execute atomically — no separate count + insert race.
let result = sqlx::query(
r#"
INSERT INTO api_tokens
(id, token_hash, owner_pubkey, name, scopes, channel_ids, expires_at, created_by_self_mint)
SELECT ?, ?, ?, ?, ?, ?, ?, TRUE
FROM DUAL
WHERE (
SELECT COUNT(*)
FROM api_tokens
WHERE owner_pubkey = ?
AND revoked_at IS NULL
AND (expires_at IS NULL OR expires_at > NOW())
) < 10
"#,
)
.bind(id_bytes)
.bind(token_hash)
.bind(owner_pubkey)
.bind(name)
.bind(&scopes_json)
.bind(&channel_ids_json)
.bind(expires_at)
.bind(owner_pubkey)
.execute(pool)
.await?;
if result.rows_affected() == 0 {
// Limit exceeded — the WHERE clause prevented the INSERT.
return Ok(None);
}
Ok(Some(id))
}
/// Look up an API token by its SHA-256 hash, **including revoked tokens**.
///
/// Unlike [`crate::Db::get_api_token_by_hash`] (which filters `revoked_at IS NULL`),
/// this function returns the full record regardless of revocation status.
/// The relay layer uses this to return distinct `token_revoked` vs `invalid_token`
/// error responses rather than treating both as "not found".
pub async fn get_api_token_by_hash_including_revoked(
pool: &MySqlPool,
hash: &[u8],
) -> Result<Option<crate::ApiTokenRecord>> {
let row = sqlx::query(
r#"
SELECT id, token_hash, owner_pubkey, name, scopes, channel_ids,
created_at, expires_at, last_used_at, revoked_at
FROM api_tokens
WHERE token_hash = ?
"#,
)
.bind(hash)
.fetch_optional(pool)
.await?;
let row = match row {
None => return Ok(None),
Some(r) => r,
};
let id_bytes: Vec<u8> = row.try_get("id")?;
let id = uuid_from_bytes(&id_bytes)?;
let scopes_json: serde_json::Value = row.try_get("scopes")?;
let scopes: Vec<String> = serde_json::from_value(scopes_json)
.map_err(|e| DbError::InvalidData(format!("scopes JSON: {e}")))?;
let channel_ids: Option<Vec<Uuid>> = {
let raw: Option<serde_json::Value> = row.try_get("channel_ids")?;
match raw {
None => None,
Some(v) => {
let strings: Vec<String> = serde_json::from_value(v)
.map_err(|e| DbError::InvalidData(format!("channel_ids JSON: {e}")))?;
let uuids: std::result::Result<Vec<Uuid>, _> =
strings.iter().map(|s| s.parse::<Uuid>()).collect();
Some(uuids.map_err(|e| DbError::InvalidData(format!("channel_ids UUID: {e}")))?)
}
}
};
Ok(Some(crate::ApiTokenRecord {
id,
token_hash: row.try_get("token_hash")?,
owner_pubkey: row.try_get("owner_pubkey")?,
name: row.try_get("name")?,
scopes,
channel_ids,
created_at: row.try_get("created_at")?,
expires_at: row.try_get("expires_at")?,
last_used_at: row.try_get("last_used_at")?,
revoked_at: row.try_get("revoked_at")?,
}))
}
/// List all tokens (including revoked) for a pubkey, ordered by creation time descending.
///
/// Returns the full [`crate::ApiTokenRecord`] including `token_hash`. Callers are
/// responsible for stripping `token_hash` before returning data to clients — the
/// raw token value is never exposed after the initial mint response.
/// Used by `GET /api/tokens` to show a user their full token history.
pub async fn list_tokens_by_owner(
pool: &MySqlPool,
pubkey: &[u8],
) -> Result<Vec<crate::ApiTokenRecord>> {
let rows = sqlx::query(
r#"
SELECT id, token_hash, owner_pubkey, name, scopes, channel_ids,
created_at, expires_at, last_used_at, revoked_at
FROM api_tokens
WHERE owner_pubkey = ?
ORDER BY created_at DESC
"#,
)
.bind(pubkey)
.fetch_all(pool)
.await?;
let mut out = Vec::with_capacity(rows.len());
for row in rows {
let id_bytes: Vec<u8> = row.try_get("id")?;
let id = uuid_from_bytes(&id_bytes)?;
let scopes_json: serde_json::Value = row.try_get("scopes")?;
let scopes: Vec<String> = serde_json::from_value(scopes_json)
.map_err(|e| DbError::InvalidData(format!("scopes JSON: {e}")))?;
let channel_ids: Option<Vec<Uuid>> = {
let raw: Option<serde_json::Value> = row.try_get("channel_ids")?;
match raw {
None => None,
Some(v) => {
let strings: Vec<String> = serde_json::from_value(v)
.map_err(|e| DbError::InvalidData(format!("channel_ids JSON: {e}")))?;
let uuids: std::result::Result<Vec<Uuid>, _> =
strings.iter().map(|s| s.parse::<Uuid>()).collect();
Some(
uuids
.map_err(|e| DbError::InvalidData(format!("channel_ids UUID: {e}")))?,
)
}
}
};
out.push(crate::ApiTokenRecord {
id,
token_hash: row.try_get("token_hash")?,
owner_pubkey: row.try_get("owner_pubkey")?,
name: row.try_get("name")?,
scopes,
channel_ids,
created_at: row.try_get("created_at")?,
expires_at: row.try_get("expires_at")?,
last_used_at: row.try_get("last_used_at")?,
revoked_at: row.try_get("revoked_at")?,
});
}
Ok(out)
}
/// Revoke a single token by ID, scoped to the owner.
///
/// Only revokes if the token is owned by `owner_pubkey` and not already revoked.
/// Returns `true` if the token was revoked, `false` if not found, not owned, or already revoked.
pub async fn revoke_token(
pool: &MySqlPool,
id: Uuid,
owner_pubkey: &[u8],
revoked_by: &[u8],
) -> Result<bool> {
let id_bytes = id.as_bytes().as_slice();
let result = sqlx::query(
r#"
UPDATE api_tokens
SET revoked_at = NOW(6), revoked_by = ?
WHERE id = ?
AND owner_pubkey = ?
AND revoked_at IS NULL
"#,
)
.bind(revoked_by)
.bind(id_bytes)
.bind(owner_pubkey)
.execute(pool)
.await?;
Ok(result.rows_affected() > 0)
}
/// Revoke all active tokens for a pubkey.
///
/// Skips already-revoked tokens (idempotent). Returns the count of newly revoked tokens.
/// If all tokens are already revoked, returns 0 with no error.
pub async fn revoke_all_tokens(
pool: &MySqlPool,
owner_pubkey: &[u8],
revoked_by: &[u8],
) -> Result<u64> {
let result = sqlx::query(
r#"
UPDATE api_tokens
SET revoked_at = NOW(6), revoked_by = ?
WHERE owner_pubkey = ?
AND revoked_at IS NULL
"#,
)
.bind(revoked_by)
.bind(owner_pubkey)
.execute(pool)
.await?;
Ok(result.rows_affected())
}

View File

@@ -694,6 +694,68 @@ impl Db {
.await
}
/// Atomic conditional INSERT: create a self-minted token only if the owner has
/// fewer than 10 active (non-revoked, non-expired) tokens.
///
/// Returns `Ok(Some(uuid))` on success, `Ok(None)` if the limit is exceeded.
pub async fn create_api_token_if_under_limit(
&self,
token_hash: &[u8],
owner_pubkey: &[u8],
name: &str,
scopes: &[String],
channel_ids: Option<&[Uuid]>,
expires_at: Option<DateTime<Utc>>,
) -> Result<Option<Uuid>> {
api_token::create_api_token_if_under_limit(
&self.pool,
token_hash,
owner_pubkey,
name,
scopes,
channel_ids,
expires_at,
)
.await
}
/// Look up an API token by its SHA-256 hash, **including revoked tokens**.
///
/// Unlike [`Db::get_api_token_by_hash`], this does not filter on `revoked_at IS NULL`.
/// Used by the relay to return distinct `token_revoked` vs `invalid_token` errors.
pub async fn get_api_token_by_hash_including_revoked(
&self,
hash: &[u8],
) -> Result<Option<ApiTokenRecord>> {
api_token::get_api_token_by_hash_including_revoked(&self.pool, hash).await
}
/// List all tokens (including revoked) for a pubkey, ordered by `created_at DESC`.
///
/// Does not return `token_hash`. Used by `GET /api/tokens`.
pub async fn list_tokens_by_owner(&self, pubkey: &[u8]) -> Result<Vec<ApiTokenRecord>> {
api_token::list_tokens_by_owner(&self.pool, pubkey).await
}
/// Revoke a single token by ID, scoped to the owner.
///
/// Returns `true` if revoked, `false` if not found, not owned by caller, or already revoked.
pub async fn revoke_token(
&self,
id: Uuid,
owner_pubkey: &[u8],
revoked_by: &[u8],
) -> Result<bool> {
api_token::revoke_token(&self.pool, id, owner_pubkey, revoked_by).await
}
/// Revoke all active tokens for a pubkey. Skips already-revoked tokens (idempotent).
///
/// Returns the count of newly revoked tokens (0 if all already revoked).
pub async fn revoke_all_tokens(&self, owner_pubkey: &[u8], revoked_by: &[u8]) -> Result<u64> {
api_token::revoke_all_tokens(&self.pool, owner_pubkey, revoked_by).await
}
/// List all non-revoked, non-expired API tokens.
///
/// Returns a summary view — does not expose raw token hashes.

View File

@@ -43,6 +43,7 @@ serde_yaml = { workspace = true }
sha2 = { workspace = true }
hex = { workspace = true }
url = { workspace = true }
moka = { workspace = true }
[dev-dependencies]
sprout-core = { workspace = true, features = ["test-utils"] }

View File

@@ -13,7 +13,7 @@ use nostr::util::hex as nostr_hex;
use crate::state::AppState;
use super::{extract_auth_pubkey, internal_error};
use super::{extract_auth_context, internal_error};
/// Returns all bot/agent members visible to the authenticated user, with presence status.
///
@@ -22,7 +22,10 @@ pub async fn agents_handler(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
let ctx = extract_auth_context(&headers, &state).await?;
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::ChannelsRead)
.map_err(super::scope_error)?;
let pubkey_bytes = ctx.pubkey_bytes.clone();
// Get requester's accessible channels to filter bot channel visibility.
let accessible_channels = state

View File

@@ -16,7 +16,7 @@ use serde::Deserialize;
use crate::state::AppState;
use super::{api_error, extract_auth_pubkey, forbidden, internal_error, not_found};
use super::{api_error, extract_auth_context, forbidden, internal_error, not_found, scope_error};
// ── Request body ──────────────────────────────────────────────────────────────
@@ -178,7 +178,10 @@ pub async fn grant_approval(
Path(token): Path<String>,
body: Option<Json<ApprovalBody>>,
) -> Result<(StatusCode, Json<serde_json::Value>), (StatusCode, Json<serde_json::Value>)> {
let (pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
let ctx = extract_auth_context(&headers, &state).await?;
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::ChannelsWrite)
.map_err(scope_error)?;
let pubkey_bytes = ctx.pubkey_bytes.clone();
let approval = state
.db
@@ -197,7 +200,7 @@ pub async fn grant_approval(
return Err(api_error(StatusCode::GONE, "approval token has expired"));
}
check_approver_spec(&approval.approver_spec, &pubkey.to_hex())?;
check_approver_spec(&approval.approver_spec, &ctx.pubkey.to_hex())?;
let note = body.as_ref().and_then(|b| b.note.as_deref());
@@ -250,7 +253,10 @@ pub async fn deny_approval(
Path(token): Path<String>,
body: Option<Json<ApprovalBody>>,
) -> Result<(StatusCode, Json<serde_json::Value>), (StatusCode, Json<serde_json::Value>)> {
let (pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
let ctx = extract_auth_context(&headers, &state).await?;
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::ChannelsWrite)
.map_err(scope_error)?;
let pubkey_bytes = ctx.pubkey_bytes.clone();
let approval = state
.db
@@ -269,7 +275,7 @@ pub async fn deny_approval(
return Err(api_error(StatusCode::GONE, "approval token has expired"));
}
check_approver_spec(&approval.approver_spec, &pubkey.to_hex())?;
check_approver_spec(&approval.approver_spec, &ctx.pubkey.to_hex())?;
let note = body.as_ref().and_then(|b| b.note.as_deref());
@@ -292,7 +298,7 @@ pub async fn deny_approval(
// A run that has already transitioned to Failed/Completed/Cancelled through
// another path (e.g., timeout, manual cancel) must not be overwritten.
let run_id = approval.run_id;
let pubkey_for_msg = pubkey.to_hex();
let pubkey_for_msg = ctx.pubkey.to_hex();
let db = state.db.clone();
tokio::spawn(async move {
let run = match db.get_workflow_run(run_id).await {

View File

@@ -24,7 +24,10 @@ use sprout_db::event::EventQuery;
use crate::handlers::event::dispatch_persistent_event;
use crate::state::AppState;
use super::{api_error, check_channel_access, extract_auth_pubkey, internal_error};
use super::{
api_error, check_channel_access, check_token_channel_access, extract_auth_context,
internal_error,
};
// ── GET /api/channels/:channel_id/canvas ─────────────────────────────────────
@@ -37,11 +40,15 @@ pub async fn get_canvas(
headers: HeaderMap,
Path(channel_id_str): Path<String>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
let ctx = extract_auth_context(&headers, &state).await?;
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::ChannelsRead)
.map_err(super::scope_error)?;
let pubkey_bytes = ctx.pubkey_bytes.clone();
let channel_id = uuid::Uuid::parse_str(&channel_id_str)
.map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid channel UUID"))?;
check_token_channel_access(&ctx, &channel_id)?;
check_channel_access(&state, channel_id, &pubkey_bytes).await?;
// Query the events table for the most recent KIND_CANVAS event scoped to
@@ -105,11 +112,15 @@ pub async fn set_canvas(
Path(channel_id_str): Path<String>,
Json(body): Json<SetCanvasBody>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
let ctx = extract_auth_context(&headers, &state).await?;
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::ChannelsWrite)
.map_err(super::scope_error)?;
let pubkey_bytes = ctx.pubkey_bytes.clone();
let channel_id = uuid::Uuid::parse_str(&channel_id_str)
.map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid channel UUID"))?;
check_token_channel_access(&ctx, &channel_id)?;
check_channel_access(&state, channel_id, &pubkey_bytes).await?;
// Reject writes to archived channels (consistent with messages, metadata, etc.).

View File

@@ -19,7 +19,7 @@ use sprout_db::channel::{ChannelRecord, ChannelType, ChannelVisibility};
use crate::state::AppState;
use super::{api_error, extract_auth_pubkey, internal_error};
use super::{api_error, extract_auth_context, internal_error};
/// Query parameters for `GET /api/channels`.
#[derive(Debug, Deserialize)]
@@ -36,7 +36,10 @@ pub async fn channels_handler(
headers: HeaderMap,
Query(params): Query<ListChannelsParams>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
let ctx = extract_auth_context(&headers, &state).await?;
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::ChannelsRead)
.map_err(super::scope_error)?;
let pubkey_bytes = ctx.pubkey_bytes.clone();
let channels = state
.db
@@ -101,7 +104,10 @@ pub async fn create_channel(
headers: HeaderMap,
ExtractJson(body): ExtractJson<CreateChannelBody>,
) -> Result<(StatusCode, Json<serde_json::Value>), (StatusCode, Json<serde_json::Value>)> {
let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
let ctx = extract_auth_context(&headers, &state).await?;
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::ChannelsWrite)
.map_err(super::scope_error)?;
let pubkey_bytes = ctx.pubkey_bytes.clone();
let name = body.name.trim();
if name.is_empty() {

View File

@@ -34,7 +34,8 @@ use crate::handlers::side_effects::emit_system_message;
use crate::state::AppState;
use super::{
api_error, check_channel_access, extract_auth_pubkey, forbidden, internal_error, not_found,
api_error, check_channel_access, check_token_channel_access, extract_auth_context, forbidden,
internal_error, not_found, scope_error,
};
// ── Helpers ───────────────────────────────────────────────────────────────────
@@ -99,8 +100,12 @@ pub async fn get_channel_handler(
headers: HeaderMap,
Path(channel_id_str): Path<String>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
let ctx = extract_auth_context(&headers, &state).await?;
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::ChannelsRead)
.map_err(scope_error)?;
let pubkey_bytes = ctx.pubkey_bytes.clone();
let channel_id = parse_channel_id(&channel_id_str)?;
check_token_channel_access(&ctx, &channel_id)?;
check_channel_access(&state, channel_id, &pubkey_bytes).await?;
@@ -143,8 +148,12 @@ pub async fn update_channel_handler(
Path(channel_id_str): Path<String>,
ExtractJson(body): ExtractJson<UpdateChannelBody>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
let ctx = extract_auth_context(&headers, &state).await?;
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::ChannelsWrite)
.map_err(scope_error)?;
let pubkey_bytes = ctx.pubkey_bytes.clone();
let channel_id = parse_channel_id(&channel_id_str)?;
check_token_channel_access(&ctx, &channel_id)?;
require_owner_or_admin(&state, channel_id, &pubkey_bytes).await?;
@@ -237,8 +246,12 @@ pub async fn set_topic_handler(
Path(channel_id_str): Path<String>,
ExtractJson(body): ExtractJson<SetTopicBody>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
let ctx = extract_auth_context(&headers, &state).await?;
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::ChannelsWrite)
.map_err(scope_error)?;
let pubkey_bytes = ctx.pubkey_bytes.clone();
let channel_id = parse_channel_id(&channel_id_str)?;
check_token_channel_access(&ctx, &channel_id)?;
check_channel_access(&state, channel_id, &pubkey_bytes).await?;
@@ -300,8 +313,12 @@ pub async fn set_purpose_handler(
Path(channel_id_str): Path<String>,
ExtractJson(body): ExtractJson<SetPurposeBody>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
let ctx = extract_auth_context(&headers, &state).await?;
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::ChannelsWrite)
.map_err(scope_error)?;
let pubkey_bytes = ctx.pubkey_bytes.clone();
let channel_id = parse_channel_id(&channel_id_str)?;
check_token_channel_access(&ctx, &channel_id)?;
check_channel_access(&state, channel_id, &pubkey_bytes).await?;
@@ -359,8 +376,12 @@ pub async fn archive_channel_handler(
headers: HeaderMap,
Path(channel_id_str): Path<String>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
let ctx = extract_auth_context(&headers, &state).await?;
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::AdminChannels)
.map_err(scope_error)?;
let pubkey_bytes = ctx.pubkey_bytes.clone();
let channel_id = parse_channel_id(&channel_id_str)?;
check_token_channel_access(&ctx, &channel_id)?;
require_owner_or_admin(&state, channel_id, &pubkey_bytes).await?;
@@ -400,8 +421,12 @@ pub async fn unarchive_channel_handler(
headers: HeaderMap,
Path(channel_id_str): Path<String>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
let ctx = extract_auth_context(&headers, &state).await?;
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::AdminChannels)
.map_err(scope_error)?;
let pubkey_bytes = ctx.pubkey_bytes.clone();
let channel_id = parse_channel_id(&channel_id_str)?;
check_token_channel_access(&ctx, &channel_id)?;
require_owner_or_admin(&state, channel_id, &pubkey_bytes).await?;
@@ -441,8 +466,12 @@ pub async fn delete_channel_handler(
headers: HeaderMap,
Path(channel_id_str): Path<String>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
let ctx = extract_auth_context(&headers, &state).await?;
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::AdminChannels)
.map_err(scope_error)?;
let pubkey_bytes = ctx.pubkey_bytes.clone();
let channel_id = parse_channel_id(&channel_id_str)?;
check_token_channel_access(&ctx, &channel_id)?;
// Only channel owners may delete.
let role = state

View File

@@ -19,7 +19,7 @@ use uuid::Uuid;
use crate::handlers::side_effects::emit_system_message;
use crate::state::AppState;
use super::{api_error, extract_auth_pubkey, internal_error};
use super::{api_error, extract_auth_context, internal_error};
// ── Request / query types ─────────────────────────────────────────────────────
@@ -58,7 +58,10 @@ pub async fn open_dm_handler(
headers: HeaderMap,
ExtractJson(body): ExtractJson<OpenDmBody>,
) -> Result<(StatusCode, Json<serde_json::Value>), (StatusCode, Json<serde_json::Value>)> {
let (_pubkey, self_bytes) = extract_auth_pubkey(&headers, &state).await?;
let ctx = extract_auth_context(&headers, &state).await?;
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::MessagesWrite)
.map_err(super::scope_error)?;
let self_bytes = ctx.pubkey_bytes.clone();
if body.pubkeys.is_empty() {
return Err(api_error(
@@ -154,7 +157,10 @@ pub async fn add_dm_member_handler(
Path(channel_id_str): Path<String>,
ExtractJson(body): ExtractJson<AddDmMemberBody>,
) -> Result<(StatusCode, Json<serde_json::Value>), (StatusCode, Json<serde_json::Value>)> {
let (_pubkey, self_bytes) = extract_auth_pubkey(&headers, &state).await?;
let ctx = extract_auth_context(&headers, &state).await?;
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::MessagesWrite)
.map_err(super::scope_error)?;
let self_bytes = ctx.pubkey_bytes.clone();
let channel_id = Uuid::parse_str(&channel_id_str)
.map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid channel_id format"))?;
@@ -258,7 +264,10 @@ pub async fn list_dms_handler(
headers: HeaderMap,
Query(params): Query<ListDmsQuery>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
let (_pubkey, self_bytes) = extract_auth_pubkey(&headers, &state).await?;
let ctx = extract_auth_context(&headers, &state).await?;
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::MessagesRead)
.map_err(super::scope_error)?;
let self_bytes = ctx.pubkey_bytes.clone();
let limit = params.limit.unwrap_or(50).min(200);

View File

@@ -13,7 +13,10 @@ use axum::{
use crate::state::AppState;
use super::{api_error, check_channel_access, extract_auth_pubkey, internal_error, not_found};
use super::{
api_error, check_channel_access, check_token_channel_access, extract_auth_context,
internal_error, not_found,
};
/// Fetch a single stored event by its 64-char hex ID.
pub async fn get_event(
@@ -21,7 +24,10 @@ pub async fn get_event(
headers: HeaderMap,
Path(event_id): Path<String>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
let ctx = extract_auth_context(&headers, &state).await?;
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::MessagesRead)
.map_err(super::scope_error)?;
let pubkey_bytes = ctx.pubkey_bytes.clone();
let id_bytes = hex::decode(&event_id)
.map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid event ID"))?;
@@ -37,6 +43,9 @@ pub async fn get_event(
.ok_or_else(|| not_found("event not found"))?;
if let Some(channel_id) = stored_event.channel_id {
// Token-level channel restriction check (in addition to membership check).
// channel_id is obtained from the event's stored metadata — no extra lookup needed.
check_token_channel_access(&ctx, &channel_id)?;
check_channel_access(&state, channel_id, &pubkey_bytes).await?;
} else {
return Err(not_found("event not found"));

View File

@@ -21,7 +21,7 @@ use sprout_core::kind::{self, event_kind_u32};
use crate::state::AppState;
use super::{extract_auth_pubkey, internal_error};
use super::{extract_auth_context, internal_error};
/// Agent activity kind set — used to partition activity into agent vs channel activity.
const AGENT_KINDS: &[u32] = &[
@@ -54,7 +54,10 @@ pub async fn feed_handler(
headers: HeaderMap,
Query(params): Query<FeedParams>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
let ctx = extract_auth_context(&headers, &state).await?;
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::MessagesRead)
.map_err(super::scope_error)?;
let pubkey_bytes = ctx.pubkey_bytes.clone();
let limit = params.limit.unwrap_or(20).min(50) as i64;
let since: DateTime<Utc> = params

View File

@@ -24,7 +24,10 @@ use uuid::Uuid;
use crate::handlers::side_effects::emit_system_message;
use crate::state::AppState;
use super::{api_error, check_channel_access, extract_auth_pubkey, forbidden, internal_error};
use super::{
api_error, check_channel_access, check_token_channel_access, extract_auth_context, forbidden,
internal_error, scope_error,
};
// ── Helpers ───────────────────────────────────────────────────────────────────
@@ -72,10 +75,14 @@ pub async fn add_members(
Path(channel_id): Path<String>,
ExtractJson(body): ExtractJson<AddMembersBody>,
) -> Result<(StatusCode, Json<serde_json::Value>), (StatusCode, Json<serde_json::Value>)> {
let (_pubkey, actor_bytes) = extract_auth_pubkey(&headers, &state).await?;
let ctx = extract_auth_context(&headers, &state).await?;
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::AdminChannels)
.map_err(scope_error)?;
let actor_bytes = ctx.pubkey_bytes.clone();
let channel_id = Uuid::parse_str(&channel_id)
.map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid channel_id"))?;
check_token_channel_access(&ctx, &channel_id)?;
// Private channels require owner/admin to add members.
let channel = state
@@ -245,10 +252,14 @@ pub async fn remove_member(
headers: HeaderMap,
Path((channel_id, pubkey)): Path<(String, String)>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
let (_actor_pk, actor_bytes) = extract_auth_pubkey(&headers, &state).await?;
let ctx = extract_auth_context(&headers, &state).await?;
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::AdminChannels)
.map_err(scope_error)?;
let actor_bytes = ctx.pubkey_bytes.clone();
let channel_id = Uuid::parse_str(&channel_id)
.map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid channel_id"))?;
check_token_channel_access(&ctx, &channel_id)?;
let target_bytes = hex::decode(&pubkey)
.ok()
@@ -330,10 +341,14 @@ pub async fn list_members(
headers: HeaderMap,
Path(channel_id): Path<String>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
let ctx = extract_auth_context(&headers, &state).await?;
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::ChannelsRead)
.map_err(scope_error)?;
let pubkey_bytes = ctx.pubkey_bytes.clone();
let channel_id = Uuid::parse_str(&channel_id)
.map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid channel_id"))?;
check_token_channel_access(&ctx, &channel_id)?;
check_channel_access(&state, channel_id, &pubkey_bytes).await?;
@@ -390,7 +405,10 @@ pub async fn join_channel(
headers: HeaderMap,
Path(channel_id): Path<String>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
let ctx = extract_auth_context(&headers, &state).await?;
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::ChannelsRead)
.map_err(scope_error)?;
let pubkey_bytes = ctx.pubkey_bytes.clone();
let channel_id = Uuid::parse_str(&channel_id)
.map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid channel_id"))?;
@@ -449,7 +467,10 @@ pub async fn leave_channel(
headers: HeaderMap,
Path(channel_id): Path<String>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
let ctx = extract_auth_context(&headers, &state).await?;
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::ChannelsRead)
.map_err(scope_error)?;
let pubkey_bytes = ctx.pubkey_bytes.clone();
let channel_id = Uuid::parse_str(&channel_id)
.map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid channel_id"))?;

View File

@@ -31,7 +31,8 @@ use crate::handlers::event::dispatch_persistent_event;
use crate::state::AppState;
use super::{
api_error, check_channel_access, extract_auth_pubkey, forbidden, internal_error, not_found,
api_error, check_channel_access, check_token_channel_access, extract_auth_context, forbidden,
internal_error, not_found,
};
/// Extract the effective message author from a stored event.
@@ -281,11 +282,15 @@ pub async fn send_message(
Path(channel_id_str): Path<String>,
Json(body): Json<SendMessageBody>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
let ctx = extract_auth_context(&headers, &state).await?;
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::MessagesWrite)
.map_err(super::scope_error)?;
let pubkey_bytes = ctx.pubkey_bytes.clone();
let channel_id = uuid::Uuid::parse_str(&channel_id_str)
.map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid channel UUID"))?;
check_token_channel_access(&ctx, &channel_id)?;
check_channel_access(&state, channel_id, &pubkey_bytes).await?;
let channel = state
@@ -499,7 +504,10 @@ pub async fn delete_message(
headers: HeaderMap,
Path(event_id_hex): Path<String>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
let ctx = extract_auth_context(&headers, &state).await?;
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::MessagesWrite)
.map_err(super::scope_error)?;
let pubkey_bytes = ctx.pubkey_bytes.clone();
let event_id_bytes = nostr_hex::decode(&event_id_hex)
.map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid event_id hex"))?;
@@ -516,6 +524,9 @@ pub async fn delete_message(
.channel_id
.ok_or_else(|| api_error(StatusCode::BAD_REQUEST, "event has no channel"))?;
// Token-level channel restriction check (channel_id from event lookup).
check_token_channel_access(&ctx, &channel_id)?;
// Auth: must be the message author OR an owner/admin of the channel.
// For relay-signed REST messages, the real author is in the p tag.
let author_bytes = effective_author(&stored.event, &state.relay_keypair.public_key());
@@ -589,11 +600,15 @@ pub async fn list_messages(
Path(channel_id_str): Path<String>,
Query(params): Query<ListMessagesParams>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
let ctx = extract_auth_context(&headers, &state).await?;
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::MessagesRead)
.map_err(super::scope_error)?;
let pubkey_bytes = ctx.pubkey_bytes.clone();
let channel_id = uuid::Uuid::parse_str(&channel_id_str)
.map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid channel UUID"))?;
check_token_channel_access(&ctx, &channel_id)?;
check_channel_access(&state, channel_id, &pubkey_bytes).await?;
let limit = params.limit.unwrap_or(50).min(200);
@@ -704,11 +719,15 @@ pub async fn get_thread(
Path((channel_id_str, event_id_hex)): Path<(String, String)>,
Query(params): Query<GetThreadParams>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
let ctx = extract_auth_context(&headers, &state).await?;
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::MessagesRead)
.map_err(super::scope_error)?;
let pubkey_bytes = ctx.pubkey_bytes.clone();
let channel_id = uuid::Uuid::parse_str(&channel_id_str)
.map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid channel UUID"))?;
check_token_channel_access(&ctx, &channel_id)?;
check_channel_access(&state, channel_id, &pubkey_bytes).await?;
let root_id_bytes = nostr_hex::decode(&event_id_hex)

View File

@@ -38,6 +38,8 @@ pub mod presence;
pub mod reactions;
/// Full-text search endpoint.
pub mod search;
/// Self-service API token minting, listing, and revocation endpoints.
pub mod tokens;
/// User profile endpoints.
pub mod users;
/// Shared helpers for workflow API handlers.
@@ -73,15 +75,366 @@ pub use workflows::{
// ── Shared helpers ────────────────────────────────────────────────────────────
use std::collections::HashMap;
use std::time::{Duration, Instant};
use axum::{
http::{HeaderMap, StatusCode},
response::Json,
};
use sha2::{Digest, Sha256};
use uuid::Uuid;
use sprout_auth::Scope;
use crate::state::AppState;
// ── Auth context types ────────────────────────────────────────────────────────
/// How the REST request was authenticated.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RestAuthMethod {
/// `Authorization: Bearer sprout_*` — API token verified against DB hash.
ApiToken,
/// `Authorization: Bearer eyJ*` — Okta JWT validated via JWKS.
OktaJwt,
/// `Authorization: Nostr <base64>` — NIP-98 HTTP Auth (bootstrap path only).
Nip98,
/// `X-Pubkey: <hex>` — dev mode only (`require_auth_token=false`).
DevPubkey,
}
/// Full authentication context returned to REST handlers.
///
/// Replaces the old `(pubkey, pubkey_bytes)` tuple from `extract_auth_pubkey`.
/// The `pubkey` and `pubkey_bytes` fields are identical to the old return value,
/// so existing handler logic is unchanged; scope and channel checks can now be
/// layered on top.
#[derive(Debug, Clone)]
pub struct RestAuthContext {
/// The authenticated Nostr public key.
pub pubkey: nostr::PublicKey,
/// Compressed (32-byte) serialisation of `pubkey`.
pub pubkey_bytes: Vec<u8>,
/// Permission scopes granted to this request.
///
/// Empty for the NIP-98 bootstrap path — the caller must be `POST /api/tokens`.
pub scopes: Vec<Scope>,
/// How the request was authenticated.
pub auth_method: RestAuthMethod,
/// The UUID of the API token used, if auth_method is `ApiToken`.
pub token_id: Option<Uuid>,
/// Token-level channel restriction, if any.
///
/// `None` means unrestricted (all channels the pubkey is a member of).
/// `Some([])` means no channels are permitted.
pub channel_ids: Option<Vec<Uuid>>,
}
/// Extract the full auth context from request headers.
///
/// Auth resolution order:
/// 1. `Authorization: Bearer sprout_*` — API token; revocation + expiry checked here
/// 2. `Authorization: Bearer eyJ*` — Okta JWT
/// 3. `X-Pubkey: <hex>` — dev mode only
///
/// NIP-98 (`Authorization: Nostr <base64>`) is **not** handled here — it is only
/// valid for `POST /api/tokens` and is verified directly in that handler. Any
/// request that sends a `Nostr` auth header to a non-token endpoint will receive
/// a 401 with `"nip98_not_supported"`.
///
/// Returns a populated [`RestAuthContext`] on success, or a 401 response on failure.
pub(crate) async fn extract_auth_context(
headers: &HeaderMap,
state: &AppState,
) -> Result<RestAuthContext, (StatusCode, Json<serde_json::Value>)> {
let require_auth = state.config.require_auth_token;
if let Some(auth_header) = headers.get("authorization").and_then(|v| v.to_str().ok()) {
// ── 1. Reject NIP-98 on non-token endpoints ───────────────────────────
// NIP-98 auth is only valid for POST /api/tokens (handled directly in
// post_tokens). Sending it here is a client error — reject explicitly
// rather than falling through to a confusing "authentication failed".
if auth_header.starts_with("Nostr ") {
tracing::warn!("auth: NIP-98 auth header sent to non-token endpoint");
return Err((
StatusCode::UNAUTHORIZED,
Json(serde_json::json!({
"error": "nip98_not_supported",
"message": "NIP-98 auth is only supported for POST /api/tokens"
})),
));
}
// ── 2. Bearer token path ──────────────────────────────────────────────
if let Some(token) = auth_header.strip_prefix("Bearer ") {
// ── 2a. API token (sprout_*) ──────────────────────────────────────
if token.starts_with("sprout_") {
let hash: [u8; 32] = Sha256::digest(token.as_bytes()).into();
// Use the new including-revoked query so we can return distinct
// token_revoked vs invalid_token errors.
let record = match state
.db
.get_api_token_by_hash_including_revoked(&hash)
.await
{
Ok(Some(r)) => r,
Ok(None) => {
tracing::warn!("auth: API token not found");
return Err((
StatusCode::UNAUTHORIZED,
Json(serde_json::json!({ "error": "invalid_token" })),
));
}
Err(e) => {
tracing::warn!("auth: API token lookup failed: {e}");
return Err((
StatusCode::UNAUTHORIZED,
Json(serde_json::json!({ "error": "invalid_token" })),
));
}
};
// Relay-layer revocation check (before hash verification).
if record.revoked_at.is_some() {
tracing::warn!("auth: API token is revoked");
return Err((
StatusCode::UNAUTHORIZED,
Json(serde_json::json!({ "error": "token_revoked" })),
));
}
// Relay-layer expiry check (before hash verification).
if let Some(exp) = record.expires_at {
if exp < chrono::Utc::now() {
tracing::warn!("auth: API token is expired");
return Err((
StatusCode::UNAUTHORIZED,
Json(serde_json::json!({ "error": "token_expired" })),
));
}
}
let owner_pubkey = match nostr::PublicKey::from_slice(&record.owner_pubkey) {
Ok(pk) => pk,
Err(e) => {
tracing::warn!("auth: API token owner pubkey invalid: {e}");
return Err((
StatusCode::UNAUTHORIZED,
Json(serde_json::json!({ "error": "invalid_token" })),
));
}
};
match state.auth.verify_api_token_against_hash(
token,
&record.token_hash,
&owner_pubkey,
&owner_pubkey,
record.expires_at,
&record.scopes,
) {
Ok((pubkey, scopes)) => {
let pubkey_bytes = pubkey.serialize().to_vec();
if let Err(e) = state.db.ensure_user(&pubkey_bytes).await {
tracing::warn!("ensure_user failed: {e}");
}
// Debounced last_used_at update — at most once per 5 min per token.
let should_update = state
.last_used_cache
.get(&record.id)
.map(|t| t.elapsed() > Duration::from_secs(300))
.unwrap_or(true);
if should_update {
state.last_used_cache.insert(record.id, Instant::now());
let db = state.db.clone();
let hash_copy = hash;
tokio::spawn(async move {
let _ = db.update_token_last_used(&hash_copy).await;
});
}
return Ok(RestAuthContext {
pubkey,
pubkey_bytes,
scopes,
auth_method: RestAuthMethod::ApiToken,
token_id: Some(record.id),
channel_ids: record.channel_ids,
});
}
Err(_) => {
tracing::warn!("auth: API token hash verification failed");
return Err((
StatusCode::UNAUTHORIZED,
Json(serde_json::json!({ "error": "invalid_token" })),
));
}
}
}
// ── 2b. Okta JWT (eyJ*) ───────────────────────────────────────────
if require_auth {
match state.auth.validate_bearer_jwt(token).await {
Ok((pubkey, scopes)) => {
let pubkey_bytes = pubkey.serialize().to_vec();
if let Err(e) = state.db.ensure_user(&pubkey_bytes).await {
tracing::warn!("ensure_user failed: {e}");
}
return Ok(RestAuthContext {
pubkey,
pubkey_bytes,
scopes,
auth_method: RestAuthMethod::OktaJwt,
token_id: None,
channel_ids: None,
});
}
Err(_) => {
tracing::warn!("auth: JWT validation failed");
return Err((
StatusCode::UNAUTHORIZED,
Json(serde_json::json!({ "error": "authentication failed" })),
));
}
}
} else {
// Dev mode: decode JWT payload without JWKS validation.
match decode_jwt_payload_unverified(token) {
Ok(claims) => {
if let Some(username) =
claims.get("preferred_username").and_then(|v| v.as_str())
{
match sprout_auth::derive_pubkey_from_username(username) {
Ok(pubkey) => {
let pubkey_bytes = pubkey.serialize().to_vec();
if let Err(e) = state.db.ensure_user(&pubkey_bytes).await {
tracing::warn!("ensure_user failed: {e}");
}
return Ok(RestAuthContext {
pubkey,
pubkey_bytes,
scopes: vec![Scope::MessagesRead, Scope::MessagesWrite],
auth_method: RestAuthMethod::OktaJwt,
token_id: None,
channel_ids: None,
});
}
Err(_) => {
tracing::warn!("auth: key derivation failed for username");
return Err((
StatusCode::UNAUTHORIZED,
Json(
serde_json::json!({ "error": "authentication failed" }),
),
));
}
}
}
tracing::warn!("auth: JWT missing preferred_username claim");
return Err((
StatusCode::UNAUTHORIZED,
Json(serde_json::json!({ "error": "authentication failed" })),
));
}
Err(_) => {
tracing::warn!("auth: malformed JWT");
return Err((
StatusCode::UNAUTHORIZED,
Json(serde_json::json!({ "error": "authentication failed" })),
));
}
}
}
}
}
// ── 4. Dev fallback: X-Pubkey ─────────────────────────────────────────────
if !require_auth {
if let Some(hex_val) = headers.get("x-pubkey").and_then(|v| v.to_str().ok()) {
match nostr::PublicKey::from_hex(hex_val) {
Ok(pubkey) => {
let pubkey_bytes = pubkey.serialize().to_vec();
if let Err(e) = state.db.ensure_user(&pubkey_bytes).await {
tracing::warn!("ensure_user failed: {e}");
}
// Dev mode grants all scopes (including admin) — it's a development convenience.
// Production deployments MUST set SPROUT_REQUIRE_AUTH_TOKEN=true.
return Ok(RestAuthContext {
pubkey,
pubkey_bytes,
scopes: Scope::all_known(),
auth_method: RestAuthMethod::DevPubkey,
token_id: None,
channel_ids: None,
});
}
Err(_) => {
tracing::warn!("auth: invalid X-Pubkey header value");
return Err((
StatusCode::UNAUTHORIZED,
Json(serde_json::json!({ "error": "authentication failed" })),
));
}
}
}
}
Err((
StatusCode::UNAUTHORIZED,
Json(serde_json::json!({ "error": "authentication required" })),
))
}
// ── Step 8: Token-level channel access enforcement ────────────────────────────
/// Check whether a token is permitted to access the given channel.
///
/// This is a **token-level** check — it verifies that `channel_id` is in the
/// token's `channel_ids` restriction list. It is **in addition to** the
/// membership check (`check_channel_access`) and scope check (`require_scope`);
/// all three must pass.
///
/// Tokens with `channel_ids = None` (no restriction) always pass this check.
/// Tokens with `channel_ids = Some([])` (empty list) deny all channels.
pub fn check_token_channel_access(
ctx: &RestAuthContext,
channel_id: &Uuid,
) -> Result<(), (StatusCode, Json<serde_json::Value>)> {
if let Some(ref allowed) = ctx.channel_ids {
if !allowed.contains(channel_id) {
return Err((
StatusCode::FORBIDDEN,
Json(serde_json::json!({
"error": "channel_not_permitted",
"message": "Token does not have access to this channel"
})),
));
}
}
Ok(())
}
/// Convert a scope-check failure into a 403 Forbidden response.
///
/// Used by handlers to propagate `require_scope` errors via `?`.
pub(crate) fn scope_error(e: sprout_auth::AuthError) -> (StatusCode, Json<serde_json::Value>) {
match e {
sprout_auth::AuthError::InsufficientScope { required, .. } => (
StatusCode::FORBIDDEN,
Json(serde_json::json!({
"error": "insufficient_scope",
"message": format!("token missing required scope: {required}"),
})),
),
other => {
tracing::warn!("scope_error: unexpected auth error: {other}");
api_error(StatusCode::FORBIDDEN, "insufficient_scope")
}
}
}
/// Standard error envelope.
pub(crate) fn api_error(status: StatusCode, msg: &str) -> (StatusCode, Json<serde_json::Value>) {
(status, Json(serde_json::json!({ "error": msg })))
@@ -116,161 +469,16 @@ fn decode_jwt_payload_unverified(
serde_json::from_slice(&decoded).map_err(|_| "authentication failed".to_string())
}
/// Extract an authenticated pubkey from the request headers.
/// Check channel membership access: member OR open-visibility channel.
///
/// Auth resolution order:
/// 1. `Authorization: Bearer sprout_*` — API token; hashed, looked up in DB.
/// 2. `Authorization: Bearer eyJ*` — Okta JWT; validated via JWKS when
/// `require_auth_token=true`, or decoded unverified (username → derived key) when
/// `require_auth_token=false`.
/// 3. `X-Pubkey: <hex>` — accepted only when `require_auth_token=false` (dev mode).
///
/// Returns `(nostr::PublicKey, pubkey_bytes)` on success, or a 401 response on failure.
pub(crate) async fn extract_auth_pubkey(
headers: &HeaderMap,
state: &AppState,
) -> Result<(nostr::PublicKey, Vec<u8>), (StatusCode, Json<serde_json::Value>)> {
let require_auth = state.config.require_auth_token;
// Try Authorization: Bearer <token>
if let Some(auth_header) = headers.get("authorization").and_then(|v| v.to_str().ok()) {
if let Some(token) = auth_header.strip_prefix("Bearer ") {
// ── API token path (sprout_*) ─────────────────────────────────
// Must be checked before the Okta JWT path: verify_auth_event()
// (and validate_bearer_jwt) would reject sprout_ tokens immediately.
if token.starts_with("sprout_") {
let hash: [u8; 32] = Sha256::digest(token.as_bytes()).into();
let record = match state.db.get_api_token_by_hash(&hash).await {
Ok(r) => r,
Err(e) => {
tracing::warn!("auth: API token lookup failed: {e}");
return Err(api_error(StatusCode::UNAUTHORIZED, "authentication failed"));
}
};
let owner_pubkey = match nostr::PublicKey::from_slice(&record.owner_pubkey) {
Ok(pk) => pk,
Err(e) => {
tracing::warn!("auth: API token owner pubkey invalid: {e}");
return Err(api_error(StatusCode::UNAUTHORIZED, "authentication failed"));
}
};
// verify_api_token_against_hash checks hash match, expiry, and
// pubkey equality. For the REST path there is no "claimed" pubkey
// from a Nostr event, so we pass the owner pubkey for both
// arguments — we are asserting identity from the DB record itself.
match state.auth.verify_api_token_against_hash(
token,
&record.token_hash,
&owner_pubkey,
&owner_pubkey,
record.expires_at,
&record.scopes,
) {
Ok((pubkey, _scopes)) => {
let bytes = pubkey.serialize().to_vec();
if let Err(e) = state.db.ensure_user(&bytes).await {
tracing::warn!("ensure_user failed: {e}");
}
// Update last_used_at — non-fatal.
let _ = state.db.update_token_last_used(&hash).await;
return Ok((pubkey, bytes));
}
Err(_) => {
tracing::warn!("auth: API token verification failed");
return Err(api_error(StatusCode::UNAUTHORIZED, "authentication failed"));
}
}
}
if require_auth {
// Production: validate JWT against JWKS
match state.auth.validate_bearer_jwt(token).await {
// NOTE: Scope enforcement is deferred to a future milestone.
// Currently all authenticated users get full API access.
Ok((pubkey, _scopes)) => {
let bytes = pubkey.serialize().to_vec();
// Auto-register user on first authentication (INSERT IGNORE — no-op if exists).
if let Err(e) = state.db.ensure_user(&bytes).await {
tracing::warn!("ensure_user failed: {e}");
// Non-fatal — don't block auth if user creation fails
}
return Ok((pubkey, bytes));
}
Err(_) => {
tracing::warn!("auth: JWT validation failed");
return Err(api_error(StatusCode::UNAUTHORIZED, "authentication failed"));
}
}
} else {
// Dev mode: decode JWT payload without JWKS validation.
match decode_jwt_payload_unverified(token) {
Ok(claims) => {
if let Some(username) =
claims.get("preferred_username").and_then(|v| v.as_str())
{
match sprout_auth::derive_pubkey_from_username(username) {
Ok(pubkey) => {
let bytes = pubkey.serialize().to_vec();
// Auto-register user on first authentication (INSERT IGNORE — no-op if exists).
if let Err(e) = state.db.ensure_user(&bytes).await {
tracing::warn!("ensure_user failed: {e}");
// Non-fatal — don't block auth if user creation fails
}
return Ok((pubkey, bytes));
}
Err(_) => {
tracing::warn!("auth: key derivation failed for username");
return Err(api_error(
StatusCode::UNAUTHORIZED,
"authentication failed",
));
}
}
}
// JWT present but no preferred_username — fail, don't silently downgrade
tracing::warn!("auth: JWT missing preferred_username claim");
return Err(api_error(StatusCode::UNAUTHORIZED, "authentication failed"));
}
Err(_) => {
// Malformed JWT — fail, don't silently downgrade to X-Pubkey
tracing::warn!("auth: malformed JWT");
return Err(api_error(StatusCode::UNAUTHORIZED, "authentication failed"));
}
}
}
}
}
// Dev fallback: X-Pubkey header (only when require_auth_token=false)
if !require_auth {
if let Some(hex_val) = headers.get("x-pubkey").and_then(|v| v.to_str().ok()) {
match nostr::PublicKey::from_hex(hex_val) {
Ok(pubkey) => {
let bytes = pubkey.serialize().to_vec();
// Auto-register user on first authentication (INSERT IGNORE — no-op if exists).
if let Err(e) = state.db.ensure_user(&bytes).await {
tracing::warn!("ensure_user failed: {e}");
// Non-fatal — don't block auth if user creation fails
}
return Ok((pubkey, bytes));
}
Err(_) => {
tracing::warn!("auth: invalid X-Pubkey header value");
return Err(api_error(StatusCode::UNAUTHORIZED, "authentication failed"));
}
}
}
}
Err(api_error(
StatusCode::UNAUTHORIZED,
"authentication required",
))
}
/// Check channel access: member OR open-visibility channel.
/// Open channels (visibility = "open") allow any authenticated user to read/write.
pub(crate) async fn check_channel_access(
/// This is the **membership** check — separate from the token-level channel restriction
/// check ([`check_token_channel_access`]).
///
/// # Note
/// This function is also exported as [`check_channel_access`] for backward compatibility
/// while Step 7 migrates all handlers to use [`extract_auth_context`].
pub(crate) async fn check_channel_membership(
state: &AppState,
channel_id: uuid::Uuid,
pubkey_bytes: &[u8],
@@ -296,6 +504,18 @@ pub(crate) async fn check_channel_access(
}
}
/// Backward-compatible alias for [`check_channel_membership`].
///
/// Step 7 will replace all call sites with `check_channel_membership` and remove this alias.
#[allow(dead_code)]
pub(crate) async fn check_channel_access(
state: &AppState,
channel_id: uuid::Uuid,
pubkey_bytes: &[u8],
) -> Result<(), (StatusCode, Json<serde_json::Value>)> {
check_channel_membership(state, channel_id, pubkey_bytes).await
}
// ── Custom JSON extractor ─────────────────────────────────────────────────────
use axum::extract::{rejection::JsonRejection, FromRequest, Request};

View File

@@ -13,7 +13,7 @@ use sprout_pubsub::presence::PRESENCE_TTL_SECS;
use crate::state::AppState;
use super::extract_auth_pubkey;
use super::extract_auth_context;
/// Query parameters for the presence endpoint.
#[derive(Debug, Deserialize)]
@@ -31,7 +31,9 @@ pub async fn presence_handler(
headers: HeaderMap,
Query(params): Query<PresenceParams>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
let (_pubkey, _pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
let ctx = extract_auth_context(&headers, &state).await?;
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::ChannelsRead)
.map_err(super::scope_error)?;
let pubkeys_param = params.pubkeys.unwrap_or_default();
@@ -91,7 +93,10 @@ pub async fn set_presence_handler(
headers: HeaderMap,
super::ApiJson(body): super::ApiJson<SetPresenceBody>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
let (pubkey, _pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
let ctx = extract_auth_context(&headers, &state).await?;
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::ChannelsRead)
.map_err(super::scope_error)?;
let pubkey = ctx.pubkey;
match body.status {
PresenceStatus::Online | PresenceStatus::Away => {

View File

@@ -26,7 +26,10 @@ use serde::Deserialize;
use crate::state::AppState;
use super::{api_error, check_channel_access, extract_auth_pubkey, internal_error, not_found};
use super::{
api_error, check_channel_access, check_token_channel_access, extract_auth_context,
internal_error, not_found,
};
// ── Request / query types ─────────────────────────────────────────────────────
@@ -78,7 +81,10 @@ pub async fn add_reaction_handler(
Path(event_id_hex): Path<String>,
axum::extract::Json(body): axum::extract::Json<AddReactionBody>,
) -> Result<(StatusCode, Json<serde_json::Value>), (StatusCode, Json<serde_json::Value>)> {
let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
let ctx = extract_auth_context(&headers, &state).await?;
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::MessagesWrite)
.map_err(super::scope_error)?;
let pubkey_bytes = ctx.pubkey_bytes.clone();
let emoji = body.emoji.trim().to_string();
if emoji.is_empty() {
@@ -97,6 +103,8 @@ pub async fn add_reaction_handler(
// Verify channel access if the event belongs to a channel.
if let Some(channel_id) = stored.channel_id {
// Token-level channel restriction check (channel_id from event lookup).
check_token_channel_access(&ctx, &channel_id)?;
check_channel_access(&state, channel_id, &pubkey_bytes).await?;
let channel = state
.db
@@ -142,7 +150,10 @@ pub async fn remove_reaction_handler(
headers: HeaderMap,
Path((event_id_hex, emoji)): Path<(String, String)>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
let ctx = extract_auth_context(&headers, &state).await?;
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::MessagesWrite)
.map_err(super::scope_error)?;
let pubkey_bytes = ctx.pubkey_bytes.clone();
let event_id_bytes = decode_event_id(&event_id_hex)?;
@@ -156,6 +167,8 @@ pub async fn remove_reaction_handler(
// Verify channel access if the event belongs to a channel.
if let Some(channel_id) = stored.channel_id {
// Token-level channel restriction check (channel_id from event lookup).
check_token_channel_access(&ctx, &channel_id)?;
check_channel_access(&state, channel_id, &pubkey_bytes).await?;
let channel = state
.db
@@ -197,7 +210,10 @@ pub async fn list_reactions_handler(
Path(event_id_hex): Path<String>,
Query(params): Query<ListReactionsParams>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
let ctx = extract_auth_context(&headers, &state).await?;
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::MessagesRead)
.map_err(super::scope_error)?;
let pubkey_bytes = ctx.pubkey_bytes.clone();
let limit = params.limit.unwrap_or(50).min(200);
let cursor = params.cursor.as_deref();
@@ -214,6 +230,8 @@ pub async fn list_reactions_handler(
// Verify channel access if the event belongs to a channel.
if let Some(channel_id) = stored.channel_id {
// Token-level channel restriction check (channel_id from event lookup).
check_token_channel_access(&ctx, &channel_id)?;
check_channel_access(&state, channel_id, &pubkey_bytes).await?;
}

View File

@@ -14,7 +14,7 @@ use sprout_search::SearchQuery;
use crate::state::AppState;
use super::extract_auth_pubkey;
use super::extract_auth_context;
/// Query parameters for the search endpoint.
#[derive(Debug, Deserialize)]
@@ -34,7 +34,10 @@ pub async fn search_handler(
headers: HeaderMap,
Query(params): Query<SearchParams>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
let ctx = extract_auth_context(&headers, &state).await?;
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::MessagesRead)
.map_err(super::scope_error)?;
let pubkey_bytes = ctx.pubkey_bytes.clone();
let query_str = params.q.unwrap_or_default();
let per_page = params.limit.unwrap_or(20).min(100);

View File

@@ -0,0 +1,792 @@
//! Self-service API token minting, listing, and revocation endpoints.
//!
//! ## Routes
//! - `POST /api/tokens` — mint a new token (NIP-98 bootstrap or Bearer)
//! - `GET /api/tokens` — list own tokens (Bearer required)
//! - `DELETE /api/tokens/{id}` — revoke one token by UUID (Bearer required)
//! - `DELETE /api/tokens` — revoke all tokens / panic button (Bearer required)
//!
//! ## Rate Limiting
//! [`MintRateLimiter`] enforces a configurable per-pubkey-per-hour limit
//! (default 50, override with `SPROUT_MINT_RATE_LIMIT`) using a bounded
//! in-memory rolling-window cache (moka). Resets on restart — acceptable since
//! the limit is a DoS guard, not a hard security cap.
use std::collections::VecDeque;
use std::sync::Arc;
use std::time::{Duration, Instant};
use axum::{
extract::{Path, State},
http::{HeaderMap, StatusCode},
response::Json,
};
use chrono::Utc;
use moka::sync::Cache;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use uuid::Uuid;
use nostr::JsonUtil;
use sprout_auth::{is_self_mintable, Scope};
use super::{api_error, extract_auth_context, internal_error, RestAuthMethod};
use crate::state::AppState;
// ── Rate limiter ──────────────────────────────────────────────────────────────
/// Default: 50 mints per pubkey per hour. Override with `SPROUT_MINT_RATE_LIMIT`.
const DEFAULT_MINT_LIMIT: usize = 50;
const MINT_WINDOW: Duration = Duration::from_secs(3600);
const RATE_LIMITER_MAX_ENTRIES: u64 = 100_000;
/// Per-pubkey rolling-window rate limiter for `POST /api/tokens`.
///
/// Uses a bounded moka cache (max 100,000 entries) to prevent OOM from an
/// attacker flooding with unique pubkeys. Evicted entries lose their window
/// history — worst case is a few extra mints, not a security bypass.
pub struct MintRateLimiter {
cache: Cache<[u8; 32], Arc<std::sync::Mutex<VecDeque<Instant>>>>,
limit: usize,
}
impl MintRateLimiter {
/// Create a new rate limiter.
///
/// `limit` is the max mints per pubkey per hour. Reads `SPROUT_MINT_RATE_LIMIT`
/// env var at construction; falls back to [`DEFAULT_MINT_LIMIT`] (50).
pub fn new() -> Self {
let limit = std::env::var("SPROUT_MINT_RATE_LIMIT")
.ok()
.and_then(|v| v.parse::<usize>().ok())
.unwrap_or(DEFAULT_MINT_LIMIT);
tracing::info!("mint rate limiter: {limit} mints/hr/pubkey");
Self {
cache: Cache::builder()
.max_capacity(RATE_LIMITER_MAX_ENTRIES)
.time_to_idle(Duration::from_secs(3600))
.build(),
limit,
}
}
/// Check whether `pubkey_bytes` is within the rate limit and record the attempt.
///
/// Returns `Ok(())` if the mint is allowed, or `Err(retry_after)` with the
/// duration until the oldest entry in the window expires.
pub fn check_and_record(&self, pubkey_bytes: &[u8; 32]) -> Result<(), Duration> {
let now = Instant::now();
let entry = self
.cache
.entry(*pubkey_bytes)
.or_insert_with(|| Arc::new(std::sync::Mutex::new(VecDeque::new())));
let mut timestamps = entry.value().lock().unwrap();
// Evict timestamps that have fallen outside the rolling window.
while timestamps
.front()
.map(|t| now.duration_since(*t) > MINT_WINDOW)
.unwrap_or(false)
{
timestamps.pop_front();
}
if timestamps.len() >= self.limit {
let retry_after = MINT_WINDOW - now.duration_since(*timestamps.front().unwrap());
return Err(retry_after);
}
timestamps.push_back(now);
Ok(())
}
/// The configured limit (for error messages).
pub fn limit(&self) -> usize {
self.limit
}
}
impl Default for MintRateLimiter {
fn default() -> Self {
Self::new()
}
}
// ── Request / response types ──────────────────────────────────────────────────
/// Request body for `POST /api/tokens`.
#[derive(Debug, Deserialize)]
pub struct MintTokenRequest {
/// Human-readable label for the token (1–100 chars).
pub name: String,
/// Scope strings to grant (e.g. `["messages:read", "channels:read"]`).
pub scopes: Vec<String>,
/// Optional channel UUIDs to restrict the token to.
/// Absent means unrestricted (unless caller's token is channel-restricted, in which case
/// channel_ids is required). Empty array is rejected for restricted callers.
pub channel_ids: Option<Vec<String>>,
/// Optional expiry in days (1–365). Omit for no expiry.
pub expires_in_days: Option<u32>,
}
/// Response body for `POST /api/tokens` (token shown once only).
#[derive(Debug, Serialize)]
pub struct MintTokenResponse {
/// Unique token identifier (UUID).
pub id: Uuid,
/// Raw token value — shown **once only**. Only the SHA-256 hash is stored.
pub token: String,
/// Human-readable label.
pub name: String,
/// Scope strings granted to this token.
pub scopes: Vec<String>,
/// Channel UUIDs this token is restricted to (empty = unrestricted).
pub channel_ids: Vec<String>,
/// ISO 8601 creation timestamp.
pub created_at: String,
/// ISO 8601 expiry timestamp, or `null` if no expiry.
pub expires_at: Option<String>,
}
/// A single token entry in the `GET /api/tokens` response.
#[derive(Debug, Serialize)]
pub struct TokenListItem {
/// Unique token identifier (UUID).
pub id: Uuid,
/// Human-readable label.
pub name: String,
/// Scope strings granted to this token.
pub scopes: Vec<String>,
/// Channel UUIDs this token is restricted to (empty = unrestricted).
pub channel_ids: Vec<String>,
/// ISO 8601 creation timestamp.
pub created_at: String,
/// ISO 8601 expiry timestamp, or `null` if no expiry.
pub expires_at: Option<String>,
/// ISO 8601 timestamp of last use, or `null` if never used.
pub last_used_at: Option<String>,
/// ISO 8601 revocation timestamp, or `null` if not revoked.
pub revoked_at: Option<String>,
}
/// Response body for `GET /api/tokens`.
#[derive(Debug, Serialize)]
pub struct TokenListResponse {
/// All tokens owned by the authenticated pubkey (including revoked).
pub tokens: Vec<TokenListItem>,
}
/// Response body for `DELETE /api/tokens` (panic button).
#[derive(Debug, Serialize)]
pub struct RevokeAllResponse {
/// Number of tokens that were newly revoked (0 if all already revoked).
pub revoked_count: u64,
}
// ── Handlers ──────────────────────────────────────────────────────────────────
/// `POST /api/tokens` — mint a new API token.
///
/// Accepts NIP-98 HTTP Auth (bootstrap — no existing token required) or an
/// existing Bearer API token. Validates scopes, rate-limits, checks channel
/// membership if `channel_ids` is provided, then inserts via the conditional
/// INSERT that enforces the 10-token-per-pubkey limit atomically.
pub async fn post_tokens(
State(state): State<std::sync::Arc<AppState>>,
headers: HeaderMap,
body: axum::body::Bytes,
) -> Result<(StatusCode, Json<serde_json::Value>), (StatusCode, Json<serde_json::Value>)> {
// Parse the request body as JSON.
let req: MintTokenRequest = serde_json::from_slice(&body).map_err(|e| {
api_error(
StatusCode::BAD_REQUEST,
&format!("invalid request body: {e}"),
)
})?;
// Extract auth context — NIP-98 or Bearer.
// For NIP-98 we re-verify here with the raw body for payload hash checking.
let ctx = if let Some(auth_header) = headers.get("authorization").and_then(|v| v.to_str().ok())
{
if let Some(encoded) = auth_header.strip_prefix("Nostr ") {
// Reconstruct canonical URL for NIP-98 verification.
let canonical_url = reconstruct_canonical_url_for_tokens(&headers, &state);
// The Authorization: Nostr header value is base64-encoded JSON.
// Decode it before passing to verify_nip98_event which expects JSON.
let decoded_bytes = {
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
BASE64.decode(encoded).map_err(|_| {
tracing::warn!("post_tokens: NIP-98 base64 decode failed");
(
StatusCode::UNAUTHORIZED,
Json(serde_json::json!({
"error": "invalid_auth",
"message": "NIP-98 verification failed"
})),
)
})?
};
let event_json = String::from_utf8(decoded_bytes).map_err(|_| {
tracing::warn!("post_tokens: NIP-98 decoded bytes are not valid UTF-8");
(
StatusCode::UNAUTHORIZED,
Json(serde_json::json!({
"error": "invalid_auth",
"message": "NIP-98 verification failed"
})),
)
})?;
match sprout_auth::verify_nip98_event(&event_json, &canonical_url, "POST", Some(&body))
{
Ok(pubkey) => {
// POST /api/tokens requires the payload tag — body must be
// cryptographically bound to the signed event.
let event: nostr::Event =
nostr::Event::from_json(&event_json).expect("already verified");
let has_payload = event.tags.find(nostr::TagKind::Payload).is_some();
if !has_payload {
tracing::warn!("post_tokens: NIP-98 event missing required payload tag");
return Err((
StatusCode::UNAUTHORIZED,
Json(serde_json::json!({
"error": "invalid_auth",
"message": "NIP-98 payload tag required for POST /api/tokens"
})),
));
}
let pubkey_bytes = pubkey.to_bytes().to_vec();
if let Err(e) = state.db.ensure_user(&pubkey_bytes).await {
tracing::warn!("ensure_user failed for NIP-98 pubkey: {e}");
}
super::RestAuthContext {
pubkey,
pubkey_bytes,
scopes: vec![],
auth_method: RestAuthMethod::Nip98,
token_id: None,
channel_ids: None,
}
}
Err(e) => {
tracing::warn!("post_tokens: NIP-98 verification failed: {e}");
return Err((
StatusCode::UNAUTHORIZED,
Json(serde_json::json!({
"error": "invalid_auth",
"message": "NIP-98 verification failed"
})),
));
}
}
} else {
extract_auth_context(&headers, &state).await?
}
} else {
extract_auth_context(&headers, &state).await?
};
// ── Validate name ─────────────────────────────────────────────────────────
if req.name.is_empty() || req.name.len() > 100 {
return Err(api_error(
StatusCode::BAD_REQUEST,
"invalid_name: name must be 1–100 characters",
));
}
// ── Validate scopes ───────────────────────────────────────────────────────
if req.scopes.is_empty() {
return Err(api_error(
StatusCode::BAD_REQUEST,
"invalid_scopes: scopes must not be empty",
));
}
let mut parsed_scopes: Vec<Scope> = Vec::with_capacity(req.scopes.len());
for s in &req.scopes {
let scope: Scope = s.parse().expect("infallible");
match &scope {
Scope::Unknown(_) => {
return Err((
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "invalid_scopes",
"message": format!("unknown scope: {s}")
})),
));
}
_ => {
if !is_self_mintable(&scope) {
return Err((
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "invalid_scopes",
"message": format!("scope requires admin: {s}")
})),
));
}
}
}
parsed_scopes.push(scope);
}
// Deduplicate scopes (order-preserving).
let mut seen = std::collections::HashSet::new();
parsed_scopes.retain(|s| seen.insert(s.clone()));
// ── Scope escalation prevention (Bearer-authenticated callers) ───────────
// If the caller authenticated via an API token, the requested scopes must be
// a subset of the caller's own scopes, and channel_ids must be a subset too.
// NIP-98 and Okta JWT callers are unrestricted (they authenticate the identity
// directly, not via a scoped token).
if matches!(ctx.auth_method, RestAuthMethod::ApiToken) {
for scope in &parsed_scopes {
if !ctx.scopes.contains(scope) {
return Err((
StatusCode::FORBIDDEN,
Json(serde_json::json!({
"error": "scope_escalation",
"message": format!("Cannot mint scope '{}' — not in your token's scopes", scope)
})),
));
}
}
// If caller has channel_ids restriction, child must also be restricted
// to a subset of those channels.
if let Some(ref caller_channels) = ctx.channel_ids {
match &req.channel_ids {
None => {
return Err((
StatusCode::FORBIDDEN,
Json(serde_json::json!({
"error": "channel_escalation",
"message": "Your token is channel-restricted; minted tokens must also specify channel_ids (subset of yours)"
})),
));
}
Some(requested_raw) => {
// Parse requested channel IDs (already validated above, but we need UUIDs here).
for raw in requested_raw {
if let Ok(cid) = raw.parse::<uuid::Uuid>() {
if !caller_channels.contains(&cid) {
return Err((
StatusCode::FORBIDDEN,
Json(serde_json::json!({
"error": "channel_escalation",
"message": format!("Cannot mint access to channel {} — not in your token's channel_ids", cid)
})),
));
}
}
}
}
}
}
}
// ── Rate limit ────────────────────────────────────────────────────────────
let pubkey_bytes_arr: [u8; 32] = ctx
.pubkey_bytes
.as_slice()
.try_into()
.map_err(|_| internal_error("pubkey bytes length mismatch"))?;
if let Err(retry_after) = state.mint_rate_limiter.check_and_record(&pubkey_bytes_arr) {
let retry_secs = retry_after.as_secs();
return Err((
StatusCode::TOO_MANY_REQUESTS,
Json(serde_json::json!({
"error": "rate_limited",
"message": format!(
"Mint limit exceeded: {} per hour. Try again in {} seconds.",
state.mint_rate_limiter.limit(), retry_secs
),
"retry_after_seconds": retry_secs
})),
));
}
// ── Validate and verify channel_ids ──────────────────────────────────────
let validated_channel_ids: Option<Vec<Uuid>> = if let Some(ref raw_ids) = req.channel_ids {
if raw_ids.is_empty() {
// Empty array is treated as "no restriction" — but if the caller's
// token is channel-restricted, this would be an escalation. The subset
// check above already rejects `None` for restricted callers, so we
// must also reject empty arrays here.
if matches!(ctx.auth_method, RestAuthMethod::ApiToken) && ctx.channel_ids.is_some() {
return Err((
StatusCode::FORBIDDEN,
Json(serde_json::json!({
"error": "channel_escalation",
"message": "Your token is channel-restricted; minted tokens must specify non-empty channel_ids (subset of yours)"
})),
));
}
None
} else {
let mut uuids = Vec::with_capacity(raw_ids.len());
for raw in raw_ids {
let cid: Uuid = raw.parse().map_err(|_| {
(
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "invalid_channel_ids",
"message": format!("malformed UUID: {raw}")
})),
)
})?;
// Verify channel exists.
let channel = state.db.get_channel(cid).await.map_err(|_| {
(
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "invalid_channel_ids",
"message": format!("channel not found: {cid}")
})),
)
})?;
let _ = channel; // existence confirmed
// Verify caller is a member of the channel.
let is_member = state
.db
.is_member(cid, &ctx.pubkey_bytes)
.await
.map_err(|e| internal_error(&format!("db error: {e}")))?;
if !is_member {
return Err((
StatusCode::FORBIDDEN,
Json(serde_json::json!({
"error": "not_channel_member",
"message": format!("not a member of channel: {cid}")
})),
));
}
uuids.push(cid);
}
// Deduplicate channel_ids (sorted; UUIDs have no meaningful order).
uuids.sort();
uuids.dedup();
Some(uuids)
}
} else {
None
};
// ── Validate expires_in_days ──────────────────────────────────────────────
if let Some(days) = req.expires_in_days {
if days == 0 || days > 365 {
return Err(api_error(
StatusCode::BAD_REQUEST,
"invalid expires_in_days: must be 1–365",
));
}
}
let expires_at = req
.expires_in_days
.map(|days| Utc::now() + chrono::Duration::days(days as i64));
// ── Generate token ────────────────────────────────────────────────────────
let raw_token = sprout_auth::generate_token();
let token_hash: Vec<u8> = Sha256::digest(raw_token.as_bytes()).to_vec();
let scope_strings: Vec<String> = parsed_scopes.iter().map(|s| s.to_string()).collect();
// ── Conditional INSERT (enforces 10-token limit atomically) ──────────────
let channel_ids_slice = validated_channel_ids.as_deref();
let token_id = state
.db
.create_api_token_if_under_limit(
&token_hash,
&ctx.pubkey_bytes,
&req.name,
&scope_strings,
channel_ids_slice,
expires_at,
)
.await
.map_err(|e| internal_error(&format!("db error creating token: {e}")))?;
let token_id = match token_id {
Some(id) => id,
None => {
return Err((
StatusCode::CONFLICT,
Json(serde_json::json!({
"error": "token_limit_exceeded",
"message": "Maximum of 10 active tokens per pubkey"
})),
));
}
};
// ── Set agent owner ─────────────────────────────────────────────────────
// Self-minted tokens always set the caller as the agent owner. This is the
// same field that `sprout-admin mint-token --owner-pubkey` sets, ensuring
// self-minted agents have the same ownership semantics as admin-minted ones.
if let Err(e) = state
.db
.set_agent_owner(&ctx.pubkey_bytes, &ctx.pubkey_bytes)
.await
{
tracing::warn!("set_agent_owner failed for self-mint: {e}");
// Non-fatal — token was already created. Owner field is a convenience,
// not a security gate. Log and continue.
}
// ── Build response ────────────────────────────────────────────────────────
let channel_ids_response: Vec<String> = validated_channel_ids
.as_deref()
.unwrap_or(&[])
.iter()
.map(|id| id.to_string())
.collect();
let resp = MintTokenResponse {
id: token_id,
token: raw_token,
name: req.name,
scopes: scope_strings,
channel_ids: channel_ids_response,
created_at: Utc::now().to_rfc3339(),
expires_at: expires_at.map(|t| t.to_rfc3339()),
};
Ok((
StatusCode::CREATED,
Json(serde_json::to_value(resp).unwrap()),
))
}
/// `GET /api/tokens` — list all tokens owned by the authenticated pubkey.
///
/// Returns all tokens including revoked ones (for audit). Token values are
/// **never** returned. Requires Bearer token or Okta JWT (not NIP-98 bootstrap).
pub async fn get_tokens(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
let ctx = extract_auth_context(&headers, &state).await?;
// NIP-98 bootstrap is not allowed for listing — caller must have a real token.
if ctx.auth_method == RestAuthMethod::Nip98 {
return Err(api_error(
StatusCode::UNAUTHORIZED,
"Bearer token required to list tokens",
));
}
let records = state
.db
.list_tokens_by_owner(&ctx.pubkey_bytes)
.await
.map_err(|e| internal_error(&format!("db error: {e}")))?;
let tokens: Vec<TokenListItem> = records
.into_iter()
.map(|r| {
let channel_ids: Vec<String> = r
.channel_ids
.as_deref()
.unwrap_or(&[])
.iter()
.map(|id| id.to_string())
.collect();
TokenListItem {
id: r.id,
name: r.name,
scopes: r.scopes,
channel_ids,
created_at: r.created_at.to_rfc3339(),
expires_at: r.expires_at.map(|t| t.to_rfc3339()),
last_used_at: r.last_used_at.map(|t| t.to_rfc3339()),
revoked_at: r.revoked_at.map(|t| t.to_rfc3339()),
}
})
.collect();
Ok(Json(serde_json::json!({ "tokens": tokens })))
}
/// `DELETE /api/tokens/{id}` — revoke a single token by UUID.
///
/// The caller must own the token. Returns 204 on success, 404 if not found
/// or not owned by the caller, 409 if already revoked.
pub async fn delete_token(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Path(id): Path<Uuid>,
) -> Result<StatusCode, (StatusCode, Json<serde_json::Value>)> {
let ctx = extract_auth_context(&headers, &state).await?;
if ctx.auth_method == RestAuthMethod::Nip98 {
return Err(api_error(
StatusCode::UNAUTHORIZED,
"Bearer token required to revoke tokens",
));
}
// First, check if the token exists and is owned by the caller — to distinguish
// "not found / not owned" (404) from "already revoked" (409).
// We use get_api_token_by_hash_including_revoked is not applicable here (we have
// the UUID, not the hash). Instead, we attempt the revoke and then check existence.
let revoked = state
.db
.revoke_token(id, &ctx.pubkey_bytes, &ctx.pubkey_bytes)
.await
.map_err(|e| internal_error(&format!("db error: {e}")))?;
if revoked {
return Ok(StatusCode::NO_CONTENT);
}
// rows_affected == 0: either not found, not owned, or already revoked.
// Check if the token exists at all (including revoked) to distinguish the cases.
let all_tokens = state
.db
.list_tokens_by_owner(&ctx.pubkey_bytes)
.await
.map_err(|e| internal_error(&format!("db error: {e}")))?;
let found = all_tokens.iter().find(|t| t.id == id);
match found {
Some(t) if t.revoked_at.is_some() => Err((
StatusCode::CONFLICT,
Json(serde_json::json!({
"error": "already_revoked",
"message": "Token is already revoked"
})),
)),
_ => Err((
StatusCode::NOT_FOUND,
Json(serde_json::json!({
"error": "not_found",
"message": "Token not found or not owned by caller"
})),
)),
}
}
/// `DELETE /api/tokens` — revoke all tokens (panic button).
///
/// Revokes all active tokens for the authenticated pubkey, including the token
/// used to make this call. Skips already-revoked tokens (idempotent).
/// Returns `{ "revoked_count": N }` with 200.
pub async fn delete_all_tokens(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
let ctx = extract_auth_context(&headers, &state).await?;
if ctx.auth_method == RestAuthMethod::Nip98 {
return Err(api_error(
StatusCode::UNAUTHORIZED,
"Bearer token required to revoke tokens",
));
}
let revoked_count = state
.db
.revoke_all_tokens(&ctx.pubkey_bytes, &ctx.pubkey_bytes)
.await
.map_err(|e| internal_error(&format!("db error: {e}")))?;
Ok(Json(serde_json::json!({ "revoked_count": revoked_count })))
}
// ── Internal helpers ──────────────────────────────────────────────────────────
/// Reconstruct the canonical URL for NIP-98 verification on the token mint endpoint.
fn reconstruct_canonical_url_for_tokens(headers: &HeaderMap, state: &AppState) -> String {
let proto = headers
.get("x-forwarded-proto")
.and_then(|v| v.to_str().ok())
.unwrap_or("https");
let host = headers
.get("x-forwarded-host")
.or_else(|| headers.get("host"))
.and_then(|v| v.to_str().ok());
if let Some(host) = host {
format!("{proto}://{host}/api/tokens")
} else {
let base = state
.config
.relay_url
.replace("wss://", "https://")
.replace("ws://", "http://");
format!("{base}/api/tokens")
}
}
// ── Tests ─────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rate_limiter_allows_up_to_limit() {
let limiter = MintRateLimiter::new();
let key = [0u8; 32];
for _ in 0..DEFAULT_MINT_LIMIT {
assert!(limiter.check_and_record(&key).is_ok());
}
// 6th call should be denied.
assert!(limiter.check_and_record(&key).is_err());
}
#[test]
fn rate_limiter_different_pubkeys_are_independent() {
let limiter = MintRateLimiter::new();
let key_a = [1u8; 32];
let key_b = [2u8; 32];
for _ in 0..DEFAULT_MINT_LIMIT {
assert!(limiter.check_and_record(&key_a).is_ok());
}
// key_b should still be allowed.
assert!(limiter.check_and_record(&key_b).is_ok());
}
#[test]
fn rate_limiter_returns_retry_after_duration() {
let limiter = MintRateLimiter::new();
let key = [3u8; 32];
for _ in 0..DEFAULT_MINT_LIMIT {
let _ = limiter.check_and_record(&key);
}
let err = limiter.check_and_record(&key).unwrap_err();
// retry_after should be ≤ MINT_WINDOW and > 0.
assert!(err > Duration::ZERO);
assert!(err <= MINT_WINDOW);
}
#[test]
fn mint_token_request_deserializes() {
let json = r#"{
"name": "my-agent",
"scopes": ["messages:read", "messages:write"],
"expires_in_days": 30
}"#;
let req: MintTokenRequest = serde_json::from_str(json).unwrap();
assert_eq!(req.name, "my-agent");
assert_eq!(req.scopes.len(), 2);
assert_eq!(req.expires_in_days, Some(30));
assert!(req.channel_ids.is_none());
}
#[test]
fn mint_token_request_with_channel_ids() {
let json = r#"{
"name": "channel-agent",
"scopes": ["messages:read"],
"channel_ids": ["01950a3b-c000-7000-8000-000000000001"]
}"#;
let req: MintTokenRequest = serde_json::from_str(json).unwrap();
assert_eq!(req.channel_ids.as_ref().unwrap().len(), 1);
}
}

View File

@@ -18,7 +18,7 @@ use serde::Deserialize;
use crate::state::AppState;
use super::{api_error, extract_auth_pubkey, internal_error};
use super::{api_error, extract_auth_context, internal_error, scope_error};
/// Request body for updating a user's profile.
/// All fields are optional — at least one must be present.
@@ -43,7 +43,9 @@ pub async fn update_profile(
headers: HeaderMap,
ExtractJson(body): ExtractJson<UpdateProfileBody>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
let ctx = extract_auth_context(&headers, &state).await?;
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::UsersWrite).map_err(scope_error)?;
let pubkey_bytes = ctx.pubkey_bytes.clone();
let display_name = body
.display_name
@@ -108,7 +110,9 @@ pub async fn get_profile(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
let ctx = extract_auth_context(&headers, &state).await?;
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::UsersRead).map_err(scope_error)?;
let pubkey_bytes = ctx.pubkey_bytes.clone();
let profile = state
.db
@@ -134,7 +138,8 @@ pub async fn get_user_profile(
headers: HeaderMap,
Path(pubkey_hex): Path<String>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
let _ = extract_auth_pubkey(&headers, &state).await?;
let ctx = extract_auth_context(&headers, &state).await?;
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::UsersRead).map_err(scope_error)?;
let pubkey_bytes = nostr_hex::decode(&pubkey_hex)
.map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid pubkey hex"))?;
@@ -174,7 +179,8 @@ pub async fn get_users_batch(
headers: HeaderMap,
ExtractJson(body): ExtractJson<BatchProfilesRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
let _ = extract_auth_pubkey(&headers, &state).await?;
let ctx = extract_auth_context(&headers, &state).await?;
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::UsersRead).map_err(scope_error)?;
if body.pubkeys.len() > 200 {
return Err(api_error(
@@ -262,7 +268,9 @@ pub async fn put_channel_add_policy(
headers: HeaderMap,
ExtractJson(body): ExtractJson<UpdateChannelAddPolicyBody>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
let (_pubkey_hex, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
let ctx = extract_auth_context(&headers, &state).await?;
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::UsersWrite).map_err(scope_error)?;
let pubkey_bytes = ctx.pubkey_bytes.clone();
let policy = body.channel_add_policy.as_str();
if !matches!(policy, "anyone" | "owner_only" | "nobody") {

View File

@@ -26,7 +26,8 @@ use super::workflow_helpers::{
validate_webhook_urls, workflow_record_to_json,
};
use super::{
api_error, check_channel_access, extract_auth_pubkey, forbidden, internal_error, not_found,
api_error, check_channel_access, check_token_channel_access, extract_auth_context, forbidden,
internal_error, not_found, scope_error,
};
// ── GET /api/channels/:channel_id/workflows ───────────────────────────────────
@@ -37,11 +38,15 @@ pub async fn list_channel_workflows(
headers: HeaderMap,
Path(channel_id_str): Path<String>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
let ctx = extract_auth_context(&headers, &state).await?;
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::ChannelsRead)
.map_err(scope_error)?;
let pubkey_bytes = ctx.pubkey_bytes.clone();
let channel_id = uuid::Uuid::parse_str(&channel_id_str)
.map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid channel UUID"))?;
check_token_channel_access(&ctx, &channel_id)?;
check_channel_access(&state, channel_id, &pubkey_bytes).await?;
let workflows = state
@@ -73,11 +78,15 @@ pub async fn create_workflow(
Path(channel_id_str): Path<String>,
Json(body): Json<CreateWorkflowBody>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
let ctx = extract_auth_context(&headers, &state).await?;
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::ChannelsWrite)
.map_err(scope_error)?;
let pubkey_bytes = ctx.pubkey_bytes.clone();
let channel_id = uuid::Uuid::parse_str(&channel_id_str)
.map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid channel UUID"))?;
check_token_channel_access(&ctx, &channel_id)?;
check_channel_access(&state, channel_id, &pubkey_bytes).await?;
let (def, definition_json_str) =
@@ -150,7 +159,10 @@ pub async fn get_workflow(
headers: HeaderMap,
Path(id_str): Path<String>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
let ctx = extract_auth_context(&headers, &state).await?;
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::ChannelsRead)
.map_err(scope_error)?;
let pubkey_bytes = ctx.pubkey_bytes.clone();
let id = uuid::Uuid::parse_str(&id_str)
.map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid workflow UUID"))?;
@@ -162,6 +174,7 @@ pub async fn get_workflow(
.map_err(|_| not_found("workflow not found"))?;
if let Some(channel_id) = workflow.channel_id {
check_token_channel_access(&ctx, &channel_id)?;
check_channel_access(&state, channel_id, &pubkey_bytes).await?;
} else if workflow.owner_pubkey != pubkey_bytes {
return Err(forbidden("not authorized to access this workflow"));
@@ -189,7 +202,10 @@ pub async fn update_workflow(
Path(id_str): Path<String>,
Json(body): Json<UpdateWorkflowBody>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
let ctx = extract_auth_context(&headers, &state).await?;
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::ChannelsWrite)
.map_err(scope_error)?;
let pubkey_bytes = ctx.pubkey_bytes.clone();
let id = uuid::Uuid::parse_str(&id_str)
.map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid workflow UUID"))?;
@@ -201,6 +217,7 @@ pub async fn update_workflow(
.map_err(|_| not_found("workflow not found"))?;
if let Some(channel_id) = existing.channel_id {
check_token_channel_access(&ctx, &channel_id)?;
check_channel_access(&state, channel_id, &pubkey_bytes).await?;
} else if existing.owner_pubkey != pubkey_bytes {
return Err(forbidden("not authorized to access this workflow"));
@@ -278,7 +295,10 @@ pub async fn delete_workflow(
headers: HeaderMap,
Path(id_str): Path<String>,
) -> Result<axum::response::Response, (StatusCode, Json<serde_json::Value>)> {
let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
let ctx = extract_auth_context(&headers, &state).await?;
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::ChannelsWrite)
.map_err(scope_error)?;
let pubkey_bytes = ctx.pubkey_bytes.clone();
let id = uuid::Uuid::parse_str(&id_str)
.map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid workflow UUID"))?;
@@ -291,6 +311,7 @@ pub async fn delete_workflow(
if workflow.owner_pubkey != pubkey_bytes {
if let Some(channel_id) = workflow.channel_id {
check_token_channel_access(&ctx, &channel_id)?;
check_channel_access(&state, channel_id, &pubkey_bytes)
.await
.map_err(|_| forbidden("not authorized to delete this workflow"))?;
@@ -325,7 +346,10 @@ pub async fn list_workflow_runs(
Path(id_str): Path<String>,
Query(params): Query<RunsParams>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
let ctx = extract_auth_context(&headers, &state).await?;
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::ChannelsRead)
.map_err(scope_error)?;
let pubkey_bytes = ctx.pubkey_bytes.clone();
let id = uuid::Uuid::parse_str(&id_str)
.map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid workflow UUID"))?;
@@ -337,6 +361,7 @@ pub async fn list_workflow_runs(
.map_err(|_| not_found("workflow not found"))?;
if let Some(channel_id) = workflow.channel_id {
check_token_channel_access(&ctx, &channel_id)?;
check_channel_access(&state, channel_id, &pubkey_bytes).await?;
}
@@ -359,7 +384,10 @@ pub async fn trigger_workflow(
headers: HeaderMap,
Path(id_str): Path<String>,
) -> Result<(StatusCode, Json<serde_json::Value>), (StatusCode, Json<serde_json::Value>)> {
let (_pubkey, pubkey_bytes) = extract_auth_pubkey(&headers, &state).await?;
let ctx = extract_auth_context(&headers, &state).await?;
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::ChannelsWrite)
.map_err(scope_error)?;
let pubkey_bytes = ctx.pubkey_bytes.clone();
let id = uuid::Uuid::parse_str(&id_str)
.map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid workflow UUID"))?;
@@ -371,6 +399,7 @@ pub async fn trigger_workflow(
.map_err(|_| not_found("workflow not found"))?;
if let Some(channel_id) = workflow.channel_id {
check_token_channel_access(&ctx, &channel_id)?;
check_channel_access(&state, channel_id, &pubkey_bytes).await?;
}

View File

@@ -14,6 +14,7 @@ use tower_http::limit::RequestBodyLimitLayer;
use tower_http::trace::TraceLayer;
use crate::api;
use crate::api::tokens;
use crate::connection::handle_connection;
use crate::nip11::{relay_info_handler, RelayInfo};
use crate::state::AppState;
@@ -25,6 +26,14 @@ pub fn build_router(state: Arc<AppState>) -> Router {
.route("/info", get(relay_info_handler))
.route("/.well-known/nostr.json", get(api::nip05::nostr_nip05))
.route("/health", get(health_handler))
// Token self-service routes
.route(
"/api/tokens",
post(tokens::post_tokens)
.get(tokens::get_tokens)
.delete(tokens::delete_all_tokens),
)
.route("/api/tokens/{id}", delete(tokens::delete_token))
.route(
"/api/channels",
get(api::channels_handler).post(api::create_channel),

View File

@@ -1,6 +1,7 @@
//! Shared application state — Arc-wrapped, shared across all connections.
use std::sync::Arc;
use std::time::Instant;
use axum::extract::ws::Message as WsMessage;
use dashmap::DashMap;
@@ -14,6 +15,7 @@ use sprout_pubsub::PubSubManager;
use sprout_search::SearchService;
use sprout_workflow::WorkflowEngine;
use crate::api::tokens::MintRateLimiter;
use crate::config::Config;
use crate::subscription::SubscriptionRegistry;
@@ -84,6 +86,12 @@ pub struct AppState {
pub workflow_engine: Arc<WorkflowEngine>,
/// Relay signing keypair — used to sign system messages (kind 40099).
pub relay_keypair: nostr::Keys,
/// Rate limiter for `POST /api/tokens` — 5 mints per pubkey per hour.
pub mint_rate_limiter: Arc<MintRateLimiter>,
/// Debounce cache for `last_used_at` token updates — avoids a DB write on every request.
/// Entries map token UUID → last time we wrote `last_used_at` to the DB.
/// Resets on restart (acceptable — `last_used_at` is informational, not security-critical).
pub last_used_cache: Arc<DashMap<Uuid, Instant>>,
}
impl AppState {
@@ -114,6 +122,8 @@ impl AppState {
handler_semaphore: Arc::new(Semaphore::new(max_concurrent_handlers)),
workflow_engine,
relay_keypair,
mint_rate_limiter: Arc::new(MintRateLimiter::new()),
last_used_cache: Arc::new(DashMap::new()),
}
}
}

View File

@@ -28,6 +28,10 @@ tracing-subscriber = { workspace = true }
uuid = { workspace = true }
futures-util = { workspace = true }
reqwest = { workspace = true }
base64 = "0.22"
sha2 = "0.10"
chrono = { workspace = true }
hex = "0.4"
[[bin]]
name = "sprout-test-cli"

View File

@@ -295,8 +295,8 @@ async fn test_mcp_initialize_and_list_tools() {
assert_eq!(
tools.len(),
42,
"expected exactly 42 tools, got {}. Tools: {:?}",
43,
"expected exactly 43 tools, got {}. Tools: {:?}",
tools.len(),
tools
.iter()

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,6 @@
-- Add created_by_self_mint column to api_tokens.
-- Tracks whether a token was minted via POST /api/tokens (self-service)
-- or via the sprout-admin CLI.
ALTER TABLE api_tokens
ADD COLUMN created_by_self_mint TINYINT(1) NOT NULL DEFAULT 0
COMMENT '1 = minted via POST /api/tokens, 0 = minted via sprout-admin CLI';