Files
buzz/crates/buzz-relay/src/api/mod.rs
Joah Gerstenberg a09b84d23d feat(relay): agents cannot write in DMs except with their owner (#9371)
## Summary

Agents stop writing in direct messages, except a DM with only their
owner. This is the relay-side backstop for block/buzz-app#3261, which
takes away the app agents' DM tools. An agent holding its own key could
still sign DM events directly, and only the relay can stop that.

A key counts as an agent when it has a stored owner
(`users.agent_owner_pubkey`, written when the agent authenticates with a
NIP-OA owner attestation). For those keys:

- **Writes into a DM channel** are refused with `restricted: agents
cannot post in direct messages; start a Solo chat that includes your
owner instead`. The check sits in `check_channel_membership` for
`ChannelWrite::Post`, so it covers stream messages, reactions,
deletions, typing and other channel ephemerals, artifact moves and edit
validation. Non-DM channels skip it at no extra cost.
- **Workflow `send_message`** (which posts as the workflow owner and
checks `may_post` directly) applies the same rule before persisting, so
an agent can't route a DM post through a workflow.
- **Put-user (9000) and edit-metadata (9002)** skip the generic gate, so
they get their own check in a DM: an agent never adds DM members with
9000 (an addition to an owner-only DM always widens it), and edits topic
or purpose only in its owner-only DM (the edit posts a system message
with the text).
- **Huddle admission** checks `may_post` directly, so it applies the
same rule to the huddle channel and its lifecycle parent.
- **DM open (41010) and DM add-member (41011)** are refused unless the
resulting participant set is exactly the agent and its owner.
- **Exemption:** a DM whose live members are exactly the agent and its
owner stays open (for example, a personal assistant's home chat).
- Lookup failures fail closed. Confirmed agent owners are cached per
community (`agent_owner_cache`, 5 min TTL). The link is first-write-wins
and never changes once set. A missing link is never cached, because
another relay process may write it later. `link_agent_owner` primes the
cache.

Humans are unaffected: they can still open DMs that include an agent and
post in them. The agent answers elsewhere.

Not covered:
- A key that never presented an owner attestation has no stored owner
and looks human.
- The huddle join commit re-check (4e) doesn't repeat the DM rule; the
pre-join admission check enforces it.
- Agents also can't delete their own earlier messages in a non-owner DM
via kind 5, since deletions pass the same `Post` gate.

### Related issue

Companion to block/buzz-app#3261. AGNTOPS-780.

### Testing

New Postgres ingest tests (`handlers::ingest::postgres_tests::agent_*`),
run locally:
- An agent is refused in a 1:1 DM with someone else and in a group DM
with its owner; the humans in both still post.
- An agent posts in a DM with only its owner.
- An agent can open a DM only with its owner; a human can still open a
DM with an agent.
- An agent can't add members to its owner DM, through 41011 or 9000.
- An agent can't set the topic in a DM with a stranger.
- An agent is refused admission to a huddle in a DM with a stranger and
admitted in its owner-only DM; a human still joins.
- An agent's workflow `send_message` is refused in a DM with a stranger
and allowed in its owner-only DM.
- An owner linked after a lookup miss is seen on the next lookup (no
negative caching).

`cargo clippy -p buzz-relay --all-targets -D warnings` is clean. The
other ingest and command-executor tests pass locally (214), except two
that fail only because of my local database: one needs the desired-state
FTS schema, and the other fails creating its restricted role in setup.
Neither reaches DM code. I pushed without the local pre-push `just
test-unit` lane because a `buzz-cli` unit test hung there for about 30
minutes; CI runs that lane.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Joah Gerstenberg <joah@squareup.com>
Co-authored-by: Hertz <c6741012416ef5c49a865db79682113385c5fb998e8c890234fb442b59e22748@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 23:18:49 -06:00

956 lines
35 KiB
Rust

//! HTTP API — media, git, NIP-05, and the Nostr HTTP bridge.
pub mod admin;
pub mod bridge;
pub mod buzz_v1;
pub mod events;
pub mod gifs;
pub mod git;
pub mod invites;
pub mod media;
/// Channel-aware media authorization.
pub mod media_access;
pub mod mesh_demo;
pub mod nip05;
pub mod nip_fi;
pub mod operator;
pub mod profiles;
pub mod workflows;
// Re-export imeta helpers used by ingest pipeline.
pub use crate::handlers::imeta::{validate_imeta_tags, verify_imeta_blobs};
use axum::{http::StatusCode, response::Json};
/// Standard error envelope.
pub(crate) fn api_error(status: StatusCode, msg: &str) -> (StatusCode, Json<serde_json::Value>) {
(status, Json(serde_json::json!({ "error": msg })))
}
pub(crate) fn internal_error(msg: &str) -> (StatusCode, Json<serde_json::Value>) {
tracing::error!("Internal error: {msg}");
api_error(StatusCode::INTERNAL_SERVER_ERROR, "internal server error")
}
/// Stable client-visible body for a read cancelled by its server-side
/// statement deadline. Clients match this string to skip retrying: a retry
/// would re-run the same expensive query.
pub(crate) const QUERY_TIMED_OUT: &str = "query timed out";
/// Stable client-visible body for a read the relay could not get a database
/// connection for (pool timeout, closed pool, socket failure), including a
/// history read shed because the reader is busy. Transient: retry later.
pub(crate) const DATABASE_UNAVAILABLE: &str = "database unavailable";
/// Map a DB read failure: a statement-deadline cancel becomes a distinct
/// 503 `query timed out`, an unreachable or saturated database a 503
/// `database unavailable`; anything else stays a generic 500.
pub(crate) fn db_read_error(
context: &str,
e: &buzz_db::DbError,
) -> (StatusCode, Json<serde_json::Value>) {
if e.is_connection_unavailable() {
tracing::warn!(error_kind = %e.kind(), "{context}");
return api_error(StatusCode::SERVICE_UNAVAILABLE, DATABASE_UNAVAILABLE);
}
read_error(context, e.is_statement_cancelled(), e.kind())
}
/// [`db_read_error`] for a search: a search cancelled by the search pool's
/// statement timeout gets the same 503 `query timed out`.
pub(crate) fn search_read_error(
context: &str,
e: &buzz_search::SearchError,
) -> (StatusCode, Json<serde_json::Value>) {
read_error(context, e.is_statement_cancelled(), e.kind())
}
/// Only the error kind is logged: DB error text can repeat a bound value.
fn read_error(
context: &str,
statement_cancelled: bool,
kind: String,
) -> (StatusCode, Json<serde_json::Value>) {
if statement_cancelled {
tracing::warn!(error_kind = %kind, "{context}");
return api_error(StatusCode::SERVICE_UNAVAILABLE, QUERY_TIMED_OUT);
}
internal_error(&format!("{context}: {kind}"))
}
#[allow(dead_code)]
pub(crate) fn not_found(msg: &str) -> (StatusCode, Json<serde_json::Value>) {
api_error(StatusCode::NOT_FOUND, msg)
}
/// Parse a raw query string into `T` after NIP-FI/NIP-98 admission has
/// already succeeded.
///
/// - Absent or empty query → `Ok(T::default())` (no params is valid).
/// - Non-empty but malformed → `Err(400 bad request)`.
///
/// **Do not use `.ok().unwrap_or_default()` here.** That pattern silently
/// discards malformed input and changes query semantics — for example,
/// `?status=open&limit=abc` would drop the valid `status=` field together
/// with the bad `limit=`, broadening the query to all statuses. Post-
/// admission a parse failure is the caller's error, not an auth failure.
/// [FI-TRACE-HTTP-INGRESS]
pub(crate) fn parse_query_or_400<T: serde::de::DeserializeOwned + Default>(
raw: Option<&str>,
) -> Result<T, (StatusCode, Json<serde_json::Value>)> {
match raw {
None | Some("") => Ok(T::default()),
Some(q) => serde_urlencoded::from_str(q)
.map_err(|e| api_error(StatusCode::BAD_REQUEST, &format!("invalid query: {e}"))),
}
}
/// Relay membership enforcement — single gate for all authenticated entry points.
///
/// Moved here from the deleted `relay_members` module. Called by `media.rs`, `bridge.rs`,
/// `git/transport.rs`, and `audio/handler.rs`.
pub mod relay_members {
use axum::{
http::{HeaderMap, StatusCode},
response::{IntoResponse, Json, Response},
};
use buzz_auth::DenialClass;
use buzz_core::{tenant::CommunityId, TenantContext};
use tracing::{debug, info};
use crate::access::AuthReason;
use crate::state::AppState;
/// Transport-neutral outcome of a relay-membership check.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MembershipDecision {
/// Relay membership enforcement is disabled.
OpenRelay,
/// Caller is directly present in `relay_members`.
Member,
/// Caller is admitted through a NIP-OA owner that is a relay member.
ViaOwner(nostr::PublicKey),
/// Caller is not admitted.
Denied,
}
/// An HTTP relay-membership refusal, with its access-log reason.
#[derive(Debug)]
pub(crate) enum MembershipRefusal {
/// The relay's own JSON refusal.
Api(AuthReason, (StatusCode, Json<serde_json::Value>)),
/// NIP-FI's fixed denial. A request carrying a verified agent token
/// gets only this, so the response never shows which step refused it
/// or whether the agent already has a stored owner.
NipFi(AuthReason, DenialClass),
}
/// A refusal decided inside [`admit_member`], before the agent-token
/// rule picks its response shape, with shadow's `owner` reason when the
/// agent token's owner decided it.
type Refused = (
AuthReason,
(StatusCode, Json<serde_json::Value>),
Option<&'static str>,
);
impl MembershipRefusal {
/// The access-log result for this refusal.
pub(crate) fn auth_result(&self) -> crate::access::AuthResult {
let (Self::Api(reason, _) | Self::NipFi(reason, _)) = self;
crate::access::AuthResult::Denied(*reason)
}
/// The response status.
pub fn status(&self) -> StatusCode {
match self {
Self::Api(_, (status, _)) => *status,
Self::NipFi(_, class) => StatusCode::from_u16(class.http_status())
.unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
}
}
}
impl IntoResponse for MembershipRefusal {
fn into_response(self) -> Response {
match self {
Self::Api(_, refusal) => refusal.into_response(),
Self::NipFi(_, class) => crate::nip_fi_core::http_denial(class),
}
}
}
/// Return the sole NIP-OA credential header, if one was supplied.
///
/// Repeated security-sensitive headers are ambiguous across HTTP stacks,
/// so they are treated as no credential instead of silently selecting one.
pub fn extract_auth_tag_header(headers: &HeaderMap) -> Option<&str> {
let mut values = headers.get_all("x-auth-tag").iter();
let (Some(value), None) = (values.next(), values.next()) else {
return None;
};
value.to_str().ok()
}
/// Check relay membership without committing to an HTTP response shape.
///
/// `community` is the server-resolved tenant of the request; membership is
/// scoped to it so admitting a pubkey to community A never admits it to B.
/// A NIP-OA credential is usable only when `signed_auth_created_at` came
/// from the already-verified authentication event carrying that request.
/// `token_owner` is the owner named by the request's verified NIP-FI agent
/// assertion; when present it is the only owner considered.
pub async fn check_relay_membership(
state: &AppState,
community: CommunityId,
pubkey_bytes: &[u8],
auth_tag_header: Option<&str>,
signed_auth_created_at: Option<u64>,
token_owner: Option<nostr::PublicKey>,
) -> Result<MembershipDecision, String> {
check_membership(
state,
community,
pubkey_bytes,
auth_tag_header,
signed_auth_created_at,
token_owner,
false,
)
.await
}
/// [`check_relay_membership`] reading principal and owner membership from
/// the writer. The final admission fence uses it: a removal whose
/// disconnect already ran must not be undone by a stale replica row.
pub async fn check_relay_membership_authoritative(
state: &AppState,
community: CommunityId,
pubkey_bytes: &[u8],
auth_tag_header: Option<&str>,
signed_auth_created_at: Option<u64>,
token_owner: Option<nostr::PublicKey>,
) -> Result<MembershipDecision, String> {
check_membership(
state,
community,
pubkey_bytes,
auth_tag_header,
signed_auth_created_at,
token_owner,
true,
)
.await
}
async fn read_membership(
state: &AppState,
community: CommunityId,
pubkey_hex: &str,
writer: bool,
) -> buzz_db::Result<bool> {
if writer {
state.db.is_relay_member_writer(community, pubkey_hex).await
} else {
state.db.is_relay_member(community, pubkey_hex).await
}
}
async fn check_membership(
state: &AppState,
community: CommunityId,
pubkey_bytes: &[u8],
auth_tag_header: Option<&str>,
signed_auth_created_at: Option<u64>,
token_owner: Option<nostr::PublicKey>,
writer: bool,
) -> Result<MembershipDecision, String> {
if !state.config.require_relay_membership {
return Ok(MembershipDecision::OpenRelay);
}
let pubkey_hex = hex::encode(pubkey_bytes);
let is_member = read_membership(state, community, &pubkey_hex, writer)
.await
.map_err(|e| format!("relay membership check failed: {e}"))?;
if is_member {
return Ok(MembershipDecision::Member);
}
// A verified agent assertion's issuer already passed this community's
// NIP-FI binding check, so its owner's membership admits the agent
// without NIP-OA.
if let Some(owner) = token_owner {
let owner_is_member = read_membership(state, community, &owner.to_hex(), writer)
.await
.map_err(|e| format!("relay membership check (token owner) failed: {e}"))?;
return Ok(if owner_is_member {
MembershipDecision::ViaOwner(owner)
} else {
MembershipDecision::Denied
});
}
if state.config.allow_nip_oa_auth {
if let Some(tag_json) = auth_tag_header {
let agent_pubkey = nostr::PublicKey::from_slice(pubkey_bytes)
.map_err(|e| format!("invalid agent pubkey for NIP-OA check: {e}"))?;
let Some(auth_created_at) = signed_auth_created_at else {
info!(agent = %pubkey_hex, "NIP-OA auth tag has no verified signed auth timestamp");
return Ok(MembershipDecision::Denied);
};
match buzz_sdk::nip_oa::verify_auth_tag_for_auth_event(
tag_json,
&agent_pubkey,
auth_created_at,
) {
Ok(owner_pubkey) => {
let owner_hex = owner_pubkey.to_hex();
let owner_is_member = read_membership(state, community, &owner_hex, writer)
.await
.map_err(|e| format!("relay membership check (owner) failed: {e}"))?;
if owner_is_member {
debug!(
agent = %pubkey_hex,
owner = %owner_hex,
"NIP-OA membership granted via owner"
);
return Ok(MembershipDecision::ViaOwner(owner_pubkey));
}
}
Err(e) => {
info!(agent = %pubkey_hex, "NIP-OA auth tag invalid: {e}");
}
}
}
}
Ok(MembershipDecision::Denied)
}
/// Enforce relay membership for a pubkey, with NIP-OA agent delegation fallback.
///
/// Returns `Ok(Some(owner_pubkey))` when the agent is not a direct member but
/// its NIP-OA owner *is* — access is granted via delegation.
///
/// On open relays (`require_relay_membership = false`), returns `Ok(None)`
/// immediately — no membership check is performed. Callers that need NIP-OA
/// owner extraction on open relays should call [`extract_nip_oa_owner`] directly.
///
/// Returns `Ok(None)` when the caller is a direct member (closed relay) or when
/// no NIP-OA tag is present/applicable (open relay without auth tag).
pub(crate) async fn enforce_relay_membership(
state: &AppState,
tenant: &TenantContext,
pubkey_bytes: &[u8],
auth_tag_header: Option<&str>,
signed_auth_created_at: Option<u64>,
token_owner: Option<nostr::PublicKey>,
) -> Result<Option<nostr::PublicKey>, MembershipRefusal> {
enforce_membership(
state,
tenant,
pubkey_bytes,
auth_tag_header,
signed_auth_created_at,
token_owner,
)
.await
}
/// With a verified agent token, every refusal is NIP-FI's fixed denial.
async fn enforce_membership(
state: &AppState,
tenant: &TenantContext,
pubkey_bytes: &[u8],
auth_tag_header: Option<&str>,
signed_auth_created_at: Option<u64>,
token_owner: Option<nostr::PublicKey>,
) -> Result<Option<nostr::PublicKey>, MembershipRefusal> {
let result = admit_member(
state,
tenant,
pubkey_bytes,
auth_tag_header,
signed_auth_created_at,
token_owner,
)
.await;
// An agent token's owner-decided refusal is its request's NIP-FI
// verdict, as shadow's verdict includes it.
if let (Some(_), Err((_, refusal, Some(owner_reason)))) = (token_owner, &result) {
crate::nip_fi_http::owner_refused(refusal_class(refusal.0), owner_reason);
}
result.map_err(|(reason, refusal, _)| match token_owner {
None => MembershipRefusal::Api(reason, refusal),
Some(_) => MembershipRefusal::NipFi(reason, refusal_class(refusal.0)),
})
}
fn refusal_class(status: StatusCode) -> DenialClass {
if status.is_server_error() {
DenialClass::AuthorizationUnavailable
} else {
DenialClass::AuthorizationDenied
}
}
async fn admit_member(
state: &AppState,
tenant: &TenantContext,
pubkey_bytes: &[u8],
auth_tag_header: Option<&str>,
signed_auth_created_at: Option<u64>,
token_owner: Option<nostr::PublicKey>,
) -> Result<Option<nostr::PublicKey>, Refused> {
let community = tenant.community();
let owner = match check_relay_membership(
state,
community,
pubkey_bytes,
auth_tag_header,
signed_auth_created_at,
token_owner,
)
.await
{
Ok(MembershipDecision::OpenRelay) | Ok(MembershipDecision::Member) => None,
Ok(MembershipDecision::ViaOwner(owner)) => Some(owner),
Ok(MembershipDecision::Denied) => {
return Err((
AuthReason::NotRelayMember,
(
StatusCode::FORBIDDEN,
Json(serde_json::json!({
"error": "relay_membership_required",
"message": "You must be a relay member to access this relay"
})),
),
None,
))
}
Err(e) => {
tracing::error!("relay membership check errored: {e}");
return Err((
AuthReason::MembershipUnavailable,
super::internal_error(&e),
None,
));
}
};
deny_banned(
state,
community,
pubkey_bytes,
auth_tag_header,
signed_auth_created_at,
token_owner,
)
.await?;
if let Some(token_owner) = token_owner {
let Ok(agent) = nostr::PublicKey::from_slice(pubkey_bytes) else {
return Err((
AuthReason::MembershipUnavailable,
super::internal_error("invalid pubkey for owner link"),
None,
));
};
match link_agent_owner(state, tenant, &agent, &token_owner).await {
OwnerLink::Linked => {}
OwnerLink::Conflict => {
return Err((
AuthReason::OwnerMismatch,
super::api_error(StatusCode::FORBIDDEN, OWNER_MISMATCH),
Some("not_stored_owner"),
))
}
OwnerLink::Unavailable => {
return Err((
AuthReason::NipOaOwnerUnlinked,
super::api_error(
StatusCode::SERVICE_UNAVAILABLE,
crate::handlers::auth::OWNER_LINK_ERROR,
),
Some("owner_link_unavailable"),
))
}
}
}
Ok(owner)
}
/// Refuse a community-banned principal (own ban or its agent owner's) on
/// HTTP. Bans only: a timeout blocks writes, and HTTP writes reach the
/// ingest gate, while reads stay allowed. Each request's grant ends with
/// the request, so this reads through the restriction cache (see
/// [`AppState::restriction_state_cached`]). Fails closed with 503.
async fn deny_banned(
state: &AppState,
community: CommunityId,
pubkey_bytes: &[u8],
auth_tag_header: Option<&str>,
signed_auth_created_at: Option<u64>,
token_owner: Option<nostr::PublicKey>,
) -> Result<(), Refused> {
let Ok(pubkey) = nostr::PublicKey::from_slice(pubkey_bytes) else {
return Err((
AuthReason::MembershipUnavailable,
super::internal_error("invalid pubkey for ban check"),
None,
));
};
let verdict = crate::handlers::auth::community_ban_verdict(
state,
community,
pubkey,
auth_tag_header,
signed_auth_created_at,
token_owner,
true,
)
.await;
let (reason, refusal) = match verdict.outcome {
crate::handlers::auth::BanOutcome::Clear => return Ok(()),
crate::handlers::auth::BanOutcome::Banned => (
AuthReason::Banned,
super::api_error(
StatusCode::FORBIDDEN,
"blocked: you are banned from this community",
),
),
crate::handlers::auth::BanOutcome::DbError => (
AuthReason::BanCheckUnavailable,
super::api_error(
StatusCode::SERVICE_UNAVAILABLE,
"error: internal error checking restriction state",
),
),
};
Err((reason, refusal, verdict.owner_reason()))
}
/// Extract NIP-OA owner from an auth tag without membership enforcement.
///
/// Used on open relays (`require_relay_membership = false`) to opportunistically
/// extract the owner pubkey for agent→owner backfill. The NIP-OA signature is
/// cryptographically self-proving, so no feature flag is needed. Temporal
/// conditions are evaluated against `signed_auth_created_at`. Returns
/// `None` if the tag, timestamp, or conditions are absent or invalid.
pub fn extract_nip_oa_owner(
pubkey_bytes: &[u8],
auth_tag_header: Option<&str>,
signed_auth_created_at: Option<u64>,
) -> Option<nostr::PublicKey> {
let tag_json = auth_tag_header?;
let auth_created_at = signed_auth_created_at?;
let agent_pubkey = nostr::PublicKey::from_slice(pubkey_bytes).ok()?;
match buzz_sdk::nip_oa::verify_auth_tag_for_auth_event(
tag_json,
&agent_pubkey,
auth_created_at,
) {
Ok(owner) => Some(owner),
Err(e) => {
info!("extract_nip_oa_owner: invalid auth tag: {e}");
None
}
}
}
/// Persist a cryptographically verified NIP-OA agent→owner relationship.
///
/// Both principals are ensured first because `agent_owner_pubkey` has a
/// community-scoped foreign key. The mapping is first-write-wins; an
/// existing mapping is accepted only when it names the same owner.
/// Returns `true` only when the link is recorded; an owner conflict and a
/// storage failure both return `false`. Callers that must tell denial
/// from unavailability use `link_agent_owner` directly.
///
/// A mapping already confirmed in `observer_owner_cache` returns early:
/// `agent_owner_pubkey` is set once and never cleared, so the writes
/// below would all be no-ops. HTTP agents call this on every request.
pub async fn materialize_nip_oa_owner(
state: &AppState,
tenant: &TenantContext,
agent: &nostr::PublicKey,
owner: &nostr::PublicKey,
) -> bool {
link_agent_owner(state, tenant, agent, owner).await == OwnerLink::Linked
}
/// Refusal when a verified agent assertion names an owner other than the
/// one already stored for the agent (NIP-FI agent keys).
pub(crate) const OWNER_MISMATCH: &str = "restricted: agent owner mismatch";
/// Result of recording an agent's owner.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OwnerLink {
/// The stored owner is now `owner`.
Linked,
/// The agent already has a different stored owner.
Conflict,
/// The link could not be read or written.
Unavailable,
}
/// The agent's stored owner compared with `owner`, or `None` when the
/// agent has no stored owner yet. Shadow and enforce both apply the
/// owner-conflict rule through this read; [`link_agent_owner`] writes only
/// when it returns `None`.
pub(crate) async fn stored_owner_link(
state: &AppState,
community: CommunityId,
agent: &nostr::PublicKey,
owner: &nostr::PublicKey,
) -> Option<OwnerLink> {
match state
.db
.get_agent_channel_policy(community, agent.as_bytes())
.await
{
Ok(Some((_, Some(stored)))) if stored.as_slice() == owner.as_bytes() => {
Some(OwnerLink::Linked)
}
Ok(Some((_, Some(_)))) => Some(OwnerLink::Conflict),
Ok(_) => None,
Err(e) => {
tracing::warn!(error = %e, "failed to read agent_owner_pubkey");
Some(OwnerLink::Unavailable)
}
}
}
/// [`materialize_nip_oa_owner`], distinguishing a different stored owner
/// from a failed read or write.
pub async fn link_agent_owner(
state: &AppState,
tenant: &TenantContext,
agent: &nostr::PublicKey,
owner: &nostr::PublicKey,
) -> OwnerLink {
let owner_key = (
tenant.community(),
agent.to_bytes().to_vec(),
owner.to_bytes().to_vec(),
);
if state.observer_owner_cache.get(&owner_key) == Some(true) {
return OwnerLink::Linked;
}
let link = match stored_owner_link(state, tenant.community(), agent, owner).await {
Some(link) => link,
None => write_agent_owner(state, tenant, agent, owner).await,
};
if link == OwnerLink::Linked {
state
.author_type_cache
.insert((tenant.community(), agent.to_bytes().to_vec()), true);
state.agent_owner_cache.insert(
(tenant.community(), agent.to_bytes().to_vec()),
owner.to_bytes().to_vec(),
);
state.observer_owner_cache.insert(owner_key, true);
}
link
}
/// Record `owner` for an agent that had none when read. The write is
/// first-write-wins, so a concurrent writer that got there first is
/// judged by the same read.
async fn write_agent_owner(
state: &AppState,
tenant: &TenantContext,
agent: &nostr::PublicKey,
owner: &nostr::PublicKey,
) -> OwnerLink {
for (role, pubkey) in [("agent", agent), ("owner", owner)] {
match state
.db
.ensure_user_for_authorization(tenant.community(), pubkey.as_bytes())
.await
{
Ok(true) => {
metrics::counter!(
"buzz_users_created_total",
"community" => tenant.host().to_owned()
)
.increment(1);
}
Ok(false) => {}
Err(e) => {
tracing::warn!(%role, error = %e, "ensure_user failed during NIP-OA backfill");
return OwnerLink::Unavailable;
}
}
}
match state
.db
.set_agent_owner_for_authorization(
tenant.community(),
agent.as_bytes(),
owner.as_bytes(),
)
.await
{
Ok(true) => {
// The owner was just recorded. Sockets this agent opened
// without it would only be found by an owner-to-agent lookup
// at revoke time; make them reconnect with the owner attached.
state.disconnect_unowned_agent_clusterwide(tenant, &agent.to_bytes());
OwnerLink::Linked
}
// Another writer stored an owner first.
Ok(false) => stored_owner_link(state, tenant.community(), agent, owner)
.await
.unwrap_or(OwnerLink::Unavailable),
Err(e) => {
tracing::warn!(error = %e, "failed to backfill agent_owner_pubkey");
OwnerLink::Unavailable
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use axum::http::{HeaderMap, HeaderValue};
use buzz_sdk::nip_oa::compute_auth_tag;
use nostr::Keys;
#[test]
fn auth_tag_header_must_be_unique() {
let mut headers = HeaderMap::new();
assert_eq!(extract_auth_tag_header(&headers), None);
headers.insert("x-auth-tag", HeaderValue::from_static("credential-one"));
assert_eq!(extract_auth_tag_header(&headers), Some("credential-one"));
headers.append("x-auth-tag", HeaderValue::from_static("credential-two"));
assert_eq!(extract_auth_tag_header(&headers), None);
}
/// Valid NIP-OA auth tag → returns Some(owner_pubkey).
#[test]
fn valid_nip_oa_returns_owner() {
let owner_keys = Keys::generate();
let agent_keys = Keys::generate();
let agent_pubkey = agent_keys.public_key();
let tag_json = compute_auth_tag(&owner_keys, &agent_pubkey, "")
.expect("compute_auth_tag must succeed");
let result = extract_nip_oa_owner(
&agent_pubkey.to_bytes(),
Some(&tag_json),
Some(nostr::Timestamp::now().as_secs()),
);
assert_eq!(result, Some(owner_keys.public_key()));
}
#[test]
fn nip_oa_time_conditions_use_signed_auth_event_time() {
let owner_keys = Keys::generate();
let agent_pubkey = Keys::generate().public_key();
let expired = compute_auth_tag(&owner_keys, &agent_pubkey, "created_at<200")
.expect("sign expired credential");
assert_eq!(
extract_nip_oa_owner(&agent_pubkey.to_bytes(), Some(&expired), Some(200)),
None
);
let future = compute_auth_tag(&owner_keys, &agent_pubkey, "created_at>200")
.expect("sign future credential");
assert_eq!(
extract_nip_oa_owner(&agent_pubkey.to_bytes(), Some(&future), Some(200)),
None
);
let in_window = compute_auth_tag(
&owner_keys,
&agent_pubkey,
"kind=9&created_at>199&created_at<201",
)
.expect("sign in-window credential");
assert_eq!(
extract_nip_oa_owner(&agent_pubkey.to_bytes(), Some(&in_window), Some(200)),
Some(owner_keys.public_key())
);
assert_eq!(
extract_nip_oa_owner(&agent_pubkey.to_bytes(), Some(&in_window), None),
None,
"a credential without a verified signed auth timestamp must fail closed"
);
}
/// No auth tag → returns None.
#[test]
fn no_auth_tag_returns_none() {
let agent_keys = Keys::generate();
let agent_pubkey = agent_keys.public_key();
let result = extract_nip_oa_owner(
&agent_pubkey.to_bytes(),
None,
Some(nostr::Timestamp::now().as_secs()),
);
assert_eq!(result, None);
}
/// Invalid auth tag → returns None.
#[test]
fn invalid_auth_tag_returns_none() {
let agent_keys = Keys::generate();
let agent_pubkey = agent_keys.public_key();
let result = extract_nip_oa_owner(
&agent_pubkey.to_bytes(),
Some("not valid json"),
Some(nostr::Timestamp::now().as_secs()),
);
assert_eq!(result, None);
}
}
}
#[cfg(test)]
mod db_read_error_tests {
use super::*;
#[test]
fn non_cancel_db_error_stays_generic_500() {
let (status, body) = db_read_error("ctx", &buzz_db::DbError::NotFound("x".into()));
assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(body.0["error"], "internal server error");
}
#[test]
fn unavailable_database_maps_to_retryable_503() {
for error in [
sqlx::Error::PoolTimedOut,
sqlx::Error::PoolClosed,
sqlx::Error::Io(std::io::Error::from(std::io::ErrorKind::ConnectionReset)),
] {
let (status, body) = db_read_error("ctx", &buzz_db::DbError::Sqlx(error));
assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
assert_eq!(body.0["error"], DATABASE_UNAVAILABLE);
}
}
#[test]
fn non_cancel_search_error_stays_generic_500() {
let (status, body) = search_read_error(
"ctx",
&buzz_search::SearchError::Db(sqlx::Error::PoolTimedOut),
);
assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(body.0["error"], "internal server error");
}
}
#[cfg(test)]
mod postgres_tests {
use super::*;
#[tokio::test]
#[ignore = "requires Postgres"]
async fn statement_timeout_maps_to_distinct_503() {
let pool = sqlx::PgPool::connect(&crate::test_support::database_url())
.await
.expect("connect to test DB");
let mut tx = pool.begin().await.expect("begin");
sqlx::query("SET LOCAL statement_timeout = '10ms'")
.execute(&mut *tx)
.await
.expect("set timeout");
let err: buzz_db::DbError = sqlx::query("SELECT pg_sleep(1)")
.execute(&mut *tx)
.await
.expect_err("statement must be cancelled")
.into();
assert!(err.is_statement_cancelled());
let (status, body) = db_read_error("ctx", &err);
assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
assert_eq!(body.0["error"], QUERY_TIMED_OUT);
}
}
// ── parse_query_or_400 regression tests ──────────────────────────────────────
#[cfg(test)]
mod parse_query_tests {
use super::parse_query_or_400;
use serde::Deserialize;
/// Mirror of `ModerationReadQuery` — independent so this module compiles
/// without pulling in handler dependencies.
#[derive(Debug, Deserialize, Default, PartialEq)]
struct QueryFixture {
status: Option<String>,
limit: Option<i64>,
}
/// Absent query → Default. No params is always valid.
#[test]
fn absent_query_gives_default() {
let result: Result<QueryFixture, _> = parse_query_or_400(None);
assert_eq!(result.unwrap(), QueryFixture::default());
}
/// Empty string → Default. Same as absent.
#[test]
fn empty_query_gives_default() {
let result: Result<QueryFixture, _> = parse_query_or_400(Some(""));
assert_eq!(result.unwrap(), QueryFixture::default());
}
/// Well-formed query → parsed correctly.
#[test]
fn valid_query_parses_correctly() {
let result: Result<QueryFixture, _> = parse_query_or_400(Some("status=open&limit=50"));
let q = result.unwrap();
assert_eq!(q.status.as_deref(), Some("open"));
assert_eq!(q.limit, Some(50));
}
/// Malformed limit → 400 error, NOT default.
///
/// Regression for the `.ok().unwrap_or_default()` bug: the old code would
/// silently discard ALL fields on any parse error, so `status=open&limit=abc`
/// would return `QueryFixture::default()` (status=None) instead of 400.
/// That made malformed input yield a BROADER query than intended.
#[test]
fn malformed_limit_is_400_not_default() {
let result: Result<QueryFixture, _> = parse_query_or_400(Some("status=open&limit=abc"));
let err = result.unwrap_err();
assert_eq!(
err.0,
axum::http::StatusCode::BAD_REQUEST,
"malformed ?limit= must return 400, not silently default \
(old bug: .ok().unwrap_or_default() would drop status= too)"
);
}
/// Malformed standalone limit → 400 error, NOT default.
#[test]
fn malformed_standalone_limit_is_400_not_default() {
let result: Result<QueryFixture, _> = parse_query_or_400(Some("limit=abc"));
let err = result.unwrap_err();
assert_eq!(
err.0,
axum::http::StatusCode::BAD_REQUEST,
"malformed ?limit=abc must return 400, not silently default to the cap"
);
}
}
mod artifact;
pub(crate) mod artifact_sync;
#[cfg(test)]
mod media_access_postgres_tests;