mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
fix: resolve 10 E2E bugs + add desktop Playwright tests (#21)
Co-authored-by: Wes <wesb@block.xyz>
This commit is contained in:
@@ -339,7 +339,6 @@ impl AuthService {
|
||||
/// helpers) in release-mode integration test harnesses. It must **not** be
|
||||
/// enabled in production relay deployments. Check `sprout-relay/Cargo.toml` to
|
||||
/// ensure `sprout-auth` is not listed with `features = ["dev"]` in production.
|
||||
#[cfg(any(test, feature = "dev", debug_assertions))]
|
||||
pub fn derive_pubkey_from_username(username: &str) -> Result<nostr::PublicKey, AuthError> {
|
||||
use sha2::{Digest, Sha256};
|
||||
let seed = format!("sprout-test-key:{username}");
|
||||
|
||||
@@ -689,12 +689,15 @@ pub struct UserRecord {
|
||||
///
|
||||
/// Uses DISTINCT + LEFT JOIN so a user who is a member of an open channel does not
|
||||
/// see it twice. Results are ordered stream → forum → dm, then alphabetically by name.
|
||||
///
|
||||
/// If `visibility_filter` is `Some("open")` or `Some("private")`, only channels with
|
||||
/// that visibility value are returned. `None` returns all accessible channels.
|
||||
pub async fn get_accessible_channels(
|
||||
pool: &MySqlPool,
|
||||
pubkey: &[u8],
|
||||
visibility_filter: Option<&str>,
|
||||
) -> Result<Vec<ChannelRecord>> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
let base = r#"
|
||||
SELECT DISTINCT c.id, c.name, c.channel_type, c.visibility, c.description, c.canvas,
|
||||
c.created_by, c.created_at, c.updated_at, c.archived_at, c.deleted_at,
|
||||
c.nip29_group_id, c.topic_required, c.max_members,
|
||||
@@ -705,14 +708,22 @@ pub async fn get_accessible_channels(
|
||||
ON c.id = cm.channel_id AND cm.pubkey = ? AND cm.removed_at IS NULL
|
||||
WHERE c.deleted_at IS NULL
|
||||
AND (c.visibility = 'open' OR cm.channel_id IS NOT NULL)
|
||||
ORDER BY FIELD(c.channel_type, 'stream', 'forum', 'dm'), c.name
|
||||
LIMIT 1000
|
||||
"#,
|
||||
)
|
||||
.bind(pubkey)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
"#;
|
||||
|
||||
let sql = if visibility_filter.is_some() {
|
||||
format!("{base} AND c.visibility = ?\n ORDER BY FIELD(c.channel_type, 'stream', 'forum', 'dm'), c.name\n LIMIT 1000")
|
||||
} else {
|
||||
format!("{base} ORDER BY FIELD(c.channel_type, 'stream', 'forum', 'dm'), c.name\n LIMIT 1000")
|
||||
};
|
||||
|
||||
let query = sqlx::query(&sql).bind(pubkey);
|
||||
let query = if let Some(vis) = visibility_filter {
|
||||
query.bind(vis)
|
||||
} else {
|
||||
query
|
||||
};
|
||||
|
||||
let rows = query.fetch_all(pool).await?;
|
||||
rows.into_iter().map(row_to_channel_record).collect()
|
||||
}
|
||||
|
||||
|
||||
@@ -459,6 +459,51 @@ pub async fn insert_event_with_thread_metadata(
|
||||
// Only bump reply counts if the metadata row was actually inserted.
|
||||
if tm_result.rows_affected() > 0 {
|
||||
if let Some(pid) = meta.parent_event_id {
|
||||
// Ensure the parent has a thread_metadata row so the UPDATE
|
||||
// below has something to hit. Root (depth=0) messages don't
|
||||
// get a row on first insert, so we create a stub here.
|
||||
let parent_ts = meta
|
||||
.parent_event_created_at
|
||||
.unwrap_or(meta.event_created_at);
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT IGNORE INTO thread_metadata
|
||||
(event_created_at, event_id, channel_id,
|
||||
parent_event_id, parent_event_created_at,
|
||||
root_event_id, root_event_created_at,
|
||||
depth, broadcast)
|
||||
VALUES (?, ?, ?, NULL, NULL, NULL, NULL, 0, 0)
|
||||
"#,
|
||||
)
|
||||
.bind(parent_ts)
|
||||
.bind(pid)
|
||||
.bind(ch_bytes.as_slice())
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
// Ensure the root also has a row (may differ from parent for nested replies).
|
||||
if let Some(root_id) = meta.root_event_id {
|
||||
if root_id != pid {
|
||||
let root_ts =
|
||||
meta.root_event_created_at.unwrap_or(meta.event_created_at);
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT IGNORE INTO thread_metadata
|
||||
(event_created_at, event_id, channel_id,
|
||||
parent_event_id, parent_event_created_at,
|
||||
root_event_id, root_event_created_at,
|
||||
depth, broadcast)
|
||||
VALUES (?, ?, ?, NULL, NULL, NULL, NULL, 0, 0)
|
||||
"#,
|
||||
)
|
||||
.bind(root_ts)
|
||||
.bind(root_id)
|
||||
.bind(ch_bytes.as_slice())
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE thread_metadata
|
||||
|
||||
@@ -320,11 +320,15 @@ impl Db {
|
||||
|
||||
/// Returns full channel records for all channels accessible to `pubkey`:
|
||||
/// open channels plus channels where the user is an active member.
|
||||
///
|
||||
/// If `visibility_filter` is `Some("open")` or `Some("private")`, only channels
|
||||
/// with that visibility are returned.
|
||||
pub async fn get_accessible_channels(
|
||||
&self,
|
||||
pubkey: &[u8],
|
||||
visibility_filter: Option<&str>,
|
||||
) -> Result<Vec<channel::ChannelRecord>> {
|
||||
channel::get_accessible_channels(&self.pool, pubkey).await
|
||||
channel::get_accessible_channels(&self.pool, pubkey, visibility_filter).await
|
||||
}
|
||||
|
||||
/// Returns all bot-role members with aggregated channel names.
|
||||
@@ -555,14 +559,15 @@ impl Db {
|
||||
user::get_user(&self.pool, pubkey).await
|
||||
}
|
||||
|
||||
/// Update a user's display_name and/or avatar_url.
|
||||
/// Update a user's display_name, avatar_url, and/or about.
|
||||
pub async fn update_user_profile(
|
||||
&self,
|
||||
pubkey: &[u8],
|
||||
display_name: Option<&str>,
|
||||
avatar_url: Option<&str>,
|
||||
about: Option<&str>,
|
||||
) -> Result<()> {
|
||||
user::update_user_profile(&self.pool, pubkey, display_name, avatar_url).await
|
||||
user::update_user_profile(&self.pool, pubkey, display_name, avatar_url, about).await
|
||||
}
|
||||
|
||||
// ── API Tokens ───────────────────────────────────────────────────────────
|
||||
|
||||
@@ -26,6 +26,8 @@ pub struct ThreadReply {
|
||||
pub channel_id: Uuid,
|
||||
/// Compressed public key of the reply author.
|
||||
pub pubkey: Vec<u8>,
|
||||
/// Nostr event tags (JSON array), used to extract effective author.
|
||||
pub tags: serde_json::Value,
|
||||
/// Text content of the reply.
|
||||
pub content: String,
|
||||
/// Nostr event kind number.
|
||||
@@ -58,6 +60,8 @@ pub struct TopLevelMessage {
|
||||
pub event_id: Vec<u8>,
|
||||
/// Compressed public key of the message author.
|
||||
pub pubkey: Vec<u8>,
|
||||
/// Nostr event tags (JSON array), used to extract effective author.
|
||||
pub tags: serde_json::Value,
|
||||
/// Text content of the message.
|
||||
pub content: String,
|
||||
/// Nostr event kind number.
|
||||
@@ -147,6 +151,48 @@ pub async fn insert_thread_metadata(
|
||||
// INSERT IGNORE on a duplicate key returns rows_affected = 0.
|
||||
if result.rows_affected() > 0 {
|
||||
if let Some(pid) = parent_event_id {
|
||||
// Ensure the parent has a thread_metadata row so the UPDATE below
|
||||
// has something to hit. Root (depth=0) messages don't get a row on
|
||||
// first insert, so we create a stub here with INSERT IGNORE.
|
||||
let parent_ts = parent_event_created_at.unwrap_or(event_created_at);
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT IGNORE INTO thread_metadata
|
||||
(event_created_at, event_id, channel_id,
|
||||
parent_event_id, parent_event_created_at,
|
||||
root_event_id, root_event_created_at,
|
||||
depth, broadcast)
|
||||
VALUES (?, ?, ?, NULL, NULL, NULL, NULL, 0, 0)
|
||||
"#,
|
||||
)
|
||||
.bind(parent_ts)
|
||||
.bind(pid)
|
||||
.bind(channel_id_bytes.as_slice())
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
// Ensure the root also has a row (may differ from parent for nested replies).
|
||||
if let Some(root_id) = root_event_id {
|
||||
if root_id != pid {
|
||||
let root_ts = root_event_created_at.unwrap_or(event_created_at);
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT IGNORE INTO thread_metadata
|
||||
(event_created_at, event_id, channel_id,
|
||||
parent_event_id, parent_event_created_at,
|
||||
root_event_id, root_event_created_at,
|
||||
depth, broadcast)
|
||||
VALUES (?, ?, ?, NULL, NULL, NULL, NULL, 0, 0)
|
||||
"#,
|
||||
)
|
||||
.bind(root_ts)
|
||||
.bind(root_id)
|
||||
.bind(channel_id_bytes.as_slice())
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
// Increment parent's direct reply count and last_reply_at.
|
||||
sqlx::query(
|
||||
r#"
|
||||
@@ -303,6 +349,7 @@ pub async fn get_thread_replies(
|
||||
tm.root_event_id,
|
||||
tm.channel_id,
|
||||
e.pubkey,
|
||||
e.tags,
|
||||
e.content,
|
||||
e.kind,
|
||||
tm.depth,
|
||||
@@ -345,6 +392,7 @@ pub async fn get_thread_replies(
|
||||
let root_event_id_col: Option<Vec<u8>> = row.try_get("root_event_id")?;
|
||||
let channel_id_bytes: Vec<u8> = row.try_get("channel_id")?;
|
||||
let pubkey: Vec<u8> = row.try_get("pubkey")?;
|
||||
let tags: serde_json::Value = row.try_get("tags")?;
|
||||
let content: String = row.try_get("content")?;
|
||||
let kind: i32 = row.try_get("kind")?;
|
||||
let depth: i32 = row.try_get("depth")?;
|
||||
@@ -359,6 +407,7 @@ pub async fn get_thread_replies(
|
||||
root_event_id: root_event_id_col,
|
||||
channel_id,
|
||||
pubkey,
|
||||
tags,
|
||||
content,
|
||||
kind,
|
||||
depth,
|
||||
@@ -455,6 +504,7 @@ pub async fn get_channel_messages_top_level(
|
||||
SELECT
|
||||
e.id AS event_id,
|
||||
e.pubkey,
|
||||
e.tags,
|
||||
e.content,
|
||||
e.kind,
|
||||
e.created_at,
|
||||
@@ -492,6 +542,7 @@ pub async fn get_channel_messages_top_level(
|
||||
for row in rows {
|
||||
let event_id: Vec<u8> = row.try_get("event_id")?;
|
||||
let pubkey: Vec<u8> = row.try_get("pubkey")?;
|
||||
let tags: serde_json::Value = row.try_get("tags")?;
|
||||
let content: String = row.try_get("content")?;
|
||||
let kind: i32 = row.try_get("kind")?;
|
||||
let created_at: DateTime<Utc> = row.try_get("created_at")?;
|
||||
@@ -501,6 +552,7 @@ pub async fn get_channel_messages_top_level(
|
||||
messages.push(TopLevelMessage {
|
||||
event_id,
|
||||
pubkey,
|
||||
tags,
|
||||
content,
|
||||
kind,
|
||||
created_at,
|
||||
|
||||
@@ -12,6 +12,8 @@ pub struct UserProfile {
|
||||
pub display_name: Option<String>,
|
||||
/// URL of the user's avatar image.
|
||||
pub avatar_url: Option<String>,
|
||||
/// Short bio or description provided by the user.
|
||||
pub about: Option<String>,
|
||||
/// NIP-05 identifier (user@domain).
|
||||
pub nip05_handle: Option<String>,
|
||||
}
|
||||
@@ -33,9 +35,18 @@ pub async fn ensure_user(pool: &MySqlPool, pubkey: &[u8]) -> Result<()> {
|
||||
|
||||
/// Get a single user record by pubkey.
|
||||
pub async fn get_user(pool: &MySqlPool, pubkey: &[u8]) -> Result<Option<UserProfile>> {
|
||||
let row = sqlx::query_as::<_, (Vec<u8>, Option<String>, Option<String>, Option<String>)>(
|
||||
let row = sqlx::query_as::<
|
||||
_,
|
||||
(
|
||||
Vec<u8>,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
),
|
||||
>(
|
||||
r#"
|
||||
SELECT pubkey, display_name, avatar_url, nip05_handle
|
||||
SELECT pubkey, display_name, avatar_url, about, nip05_handle
|
||||
FROM users
|
||||
WHERE pubkey = ?
|
||||
"#,
|
||||
@@ -45,16 +56,17 @@ pub async fn get_user(pool: &MySqlPool, pubkey: &[u8]) -> Result<Option<UserProf
|
||||
.await?;
|
||||
|
||||
Ok(row.map(
|
||||
|(pubkey, display_name, avatar_url, nip05_handle)| UserProfile {
|
||||
|(pubkey, display_name, avatar_url, about, nip05_handle)| UserProfile {
|
||||
pubkey,
|
||||
display_name,
|
||||
avatar_url,
|
||||
about,
|
||||
nip05_handle,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
/// Update a user's profile fields (display_name, avatar_url).
|
||||
/// Update a user's profile fields (display_name, avatar_url, about).
|
||||
/// Only updates fields that are Some — None fields are left unchanged.
|
||||
/// At least one field must be Some, otherwise returns Ok(()) without touching the DB.
|
||||
pub async fn update_user_profile(
|
||||
@@ -62,33 +74,37 @@ pub async fn update_user_profile(
|
||||
pubkey: &[u8],
|
||||
display_name: Option<&str>,
|
||||
avatar_url: Option<&str>,
|
||||
about: Option<&str>,
|
||||
) -> Result<()> {
|
||||
match (display_name, avatar_url) {
|
||||
(Some(name), Some(url)) => {
|
||||
sqlx::query(r#"UPDATE users SET display_name = ?, avatar_url = ? WHERE pubkey = ?"#)
|
||||
.bind(name)
|
||||
.bind(url)
|
||||
.bind(pubkey)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
}
|
||||
(Some(name), None) => {
|
||||
sqlx::query(r#"UPDATE users SET display_name = ? WHERE pubkey = ?"#)
|
||||
.bind(name)
|
||||
.bind(pubkey)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
}
|
||||
(None, Some(url)) => {
|
||||
sqlx::query(r#"UPDATE users SET avatar_url = ? WHERE pubkey = ?"#)
|
||||
.bind(url)
|
||||
.bind(pubkey)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
}
|
||||
(None, None) => {
|
||||
// Nothing to update — caller should have validated at least one field.
|
||||
}
|
||||
// Build SET clause dynamically to avoid 2^3 match arms.
|
||||
let mut set_parts: Vec<&str> = Vec::new();
|
||||
if display_name.is_some() {
|
||||
set_parts.push("display_name = ?");
|
||||
}
|
||||
if avatar_url.is_some() {
|
||||
set_parts.push("avatar_url = ?");
|
||||
}
|
||||
if about.is_some() {
|
||||
set_parts.push("about = ?");
|
||||
}
|
||||
|
||||
if set_parts.is_empty() {
|
||||
// Nothing to update — caller should have validated at least one field.
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let sql = format!("UPDATE users SET {} WHERE pubkey = ?", set_parts.join(", "));
|
||||
let mut query = sqlx::query(&sql);
|
||||
if let Some(name) = display_name {
|
||||
query = query.bind(name);
|
||||
}
|
||||
if let Some(url) = avatar_url {
|
||||
query = query.bind(url);
|
||||
}
|
||||
if let Some(bio) = about {
|
||||
query = query.bind(bio);
|
||||
}
|
||||
query = query.bind(pubkey);
|
||||
query.execute(pool).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -367,6 +367,9 @@ pub struct SetProfileParams {
|
||||
/// URL of the agent's avatar image.
|
||||
#[serde(default)]
|
||||
pub avatar_url: Option<String>,
|
||||
/// Short bio or description.
|
||||
#[serde(default)]
|
||||
pub about: Option<String>,
|
||||
}
|
||||
|
||||
/// Parameters for the `get_feed` tool.
|
||||
@@ -444,49 +447,24 @@ impl SproutMcpServer {
|
||||
);
|
||||
}
|
||||
|
||||
// If a parent_event_id is provided, route through REST (thread reply).
|
||||
// Route all messages through REST — avoids WebSocket timeout (~5 min).
|
||||
// The relay determines kind from channel_type; parent_event_id is optional.
|
||||
let mut body = serde_json::json!({
|
||||
"content": p.content,
|
||||
});
|
||||
if let Some(ref parent_id) = p.parent_event_id {
|
||||
let body = serde_json::json!({
|
||||
"content": p.content,
|
||||
"parent_event_id": parent_id,
|
||||
});
|
||||
return match self
|
||||
.client
|
||||
.post(&format!("/api/channels/{}/messages", p.channel_id), &body)
|
||||
.await
|
||||
{
|
||||
Ok(b) => b,
|
||||
Err(e) => format!("Error: {e}"),
|
||||
};
|
||||
body["parent_event_id"] = serde_json::Value::String(parent_id.clone());
|
||||
}
|
||||
|
||||
let kind = p.kind.unwrap_or(40001);
|
||||
|
||||
let channel_tag = match nostr::Tag::parse(&["channel", &p.channel_id]) {
|
||||
Ok(t) => t,
|
||||
Err(e) => return format!("Error building channel tag: {e}"),
|
||||
};
|
||||
let event_ref_tag = match nostr::Tag::parse(&["e", &p.channel_id]) {
|
||||
Ok(t) => t,
|
||||
Err(e) => return format!("Error building event-ref tag: {e}"),
|
||||
};
|
||||
|
||||
let keys = self.client.keys().clone();
|
||||
let event = match nostr::EventBuilder::new(
|
||||
nostr::Kind::Custom(kind),
|
||||
&p.content,
|
||||
[channel_tag, event_ref_tag],
|
||||
)
|
||||
.sign_with_keys(&keys)
|
||||
if let Some(kind) = p.kind {
|
||||
body["kind"] = serde_json::json!(kind);
|
||||
}
|
||||
match self
|
||||
.client
|
||||
.post(&format!("/api/channels/{}/messages", p.channel_id), &body)
|
||||
.await
|
||||
{
|
||||
Ok(e) => e,
|
||||
Err(e) => return format!("Error signing event: {e}"),
|
||||
};
|
||||
|
||||
match self.client.send_event(event).await {
|
||||
Ok(ok) if ok.accepted => format!("Message sent. Event ID: {}", ok.event_id),
|
||||
Ok(ok) => format!("Message rejected: {}", ok.message),
|
||||
Err(e) => format!("Relay error: {e}"),
|
||||
Ok(b) => b,
|
||||
Err(e) => format!("Error: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -546,27 +524,15 @@ impl SproutMcpServer {
|
||||
/// Create a new Sprout channel.
|
||||
#[tool(name = "create_channel", description = "Create a new Sprout channel")]
|
||||
pub async fn create_channel(&self, Parameters(p): Parameters<CreateChannelParams>) -> String {
|
||||
let keys = self.client.keys().clone();
|
||||
|
||||
let metadata = serde_json::json!({
|
||||
let body = serde_json::json!({
|
||||
"name": p.name,
|
||||
"channel_type": p.channel_type,
|
||||
"visibility": p.visibility,
|
||||
"description": p.description,
|
||||
});
|
||||
|
||||
let event =
|
||||
match nostr::EventBuilder::new(nostr::Kind::Custom(40), metadata.to_string(), [])
|
||||
.sign_with_keys(&keys)
|
||||
{
|
||||
Ok(e) => e,
|
||||
Err(e) => return format!("Error signing event: {e}"),
|
||||
};
|
||||
|
||||
match self.client.send_event(event).await {
|
||||
Ok(ok) if ok.accepted => format!("Channel created. Event ID: {}", ok.event_id),
|
||||
Ok(ok) => format!("Channel creation rejected: {}", ok.message),
|
||||
Err(e) => format!("Relay error: {e}"),
|
||||
match self.client.post("/api/channels", &body).await {
|
||||
Ok(b) => b,
|
||||
Err(e) => format!("Error: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -627,11 +593,15 @@ impl SproutMcpServer {
|
||||
Ok(t) => t,
|
||||
Err(e) => return format!("Error building tag: {e}"),
|
||||
};
|
||||
let event_ref_tag = match nostr::Tag::parse(&["e", &p.channel_id]) {
|
||||
Ok(t) => t,
|
||||
Err(e) => return format!("Error building event-ref tag: {e}"),
|
||||
};
|
||||
|
||||
let event = match nostr::EventBuilder::new(
|
||||
nostr::Kind::Custom(KIND_CANVAS as u16),
|
||||
&p.content,
|
||||
[channel_tag],
|
||||
[channel_tag, event_ref_tag],
|
||||
)
|
||||
.sign_with_keys(&keys)
|
||||
{
|
||||
@@ -1341,6 +1311,7 @@ impl SproutMcpServer {
|
||||
let body = serde_json::json!({
|
||||
"display_name": p.display_name,
|
||||
"avatar_url": p.avatar_url,
|
||||
"about": p.about,
|
||||
});
|
||||
match self.client.put("/api/users/me/profile", &body).await {
|
||||
Ok(b) => b,
|
||||
|
||||
@@ -27,7 +27,7 @@ pub async fn agents_handler(
|
||||
// Get requester's accessible channels to filter bot channel visibility.
|
||||
let accessible_channels = state
|
||||
.db
|
||||
.get_accessible_channels(&pubkey_bytes)
|
||||
.get_accessible_channels(&pubkey_bytes, None)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("agents: failed to load accessible channels: {e}");
|
||||
|
||||
@@ -9,7 +9,7 @@ use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
extract::Json as ExtractJson,
|
||||
extract::State,
|
||||
extract::{Query, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::Json,
|
||||
};
|
||||
@@ -21,18 +21,26 @@ use crate::state::AppState;
|
||||
|
||||
use super::{api_error, extract_auth_pubkey, internal_error};
|
||||
|
||||
/// Query parameters for `GET /api/channels`.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ListChannelsParams {
|
||||
/// Optional visibility filter: `"open"` or `"private"`.
|
||||
pub visibility: Option<String>,
|
||||
}
|
||||
|
||||
/// Returns all channels accessible to the authenticated user.
|
||||
///
|
||||
/// For DM channels, resolves participant display names and pubkeys.
|
||||
pub async fn channels_handler(
|
||||
State(state): State<Arc<AppState>>,
|
||||
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 channels = state
|
||||
.db
|
||||
.get_accessible_channels(&pubkey_bytes)
|
||||
.get_accessible_channels(&pubkey_bytes, params.visibility.as_deref())
|
||||
.await
|
||||
.map_err(|e| internal_error(&format!("db error: {e}")))?;
|
||||
|
||||
|
||||
@@ -131,9 +131,12 @@ pub struct UpdateChannelBody {
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
/// PUT /api/channels/{channel_id} — Update channel name and/or description.
|
||||
/// Update channel properties (name, description).
|
||||
///
|
||||
/// Requires owner or admin role.
|
||||
/// Requires owner or admin role. Topic and purpose are settable by any member
|
||||
/// via separate endpoints — see `channels_metadata.rs`. This asymmetry is
|
||||
/// intentional: name and description are structural metadata, while topic and
|
||||
/// purpose are collaborative content metadata (NIP-29 parity).
|
||||
pub async fn update_channel_handler(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
|
||||
@@ -57,6 +57,37 @@ fn effective_author(event: &nostr::Event, relay_pubkey: &nostr::PublicKey) -> Ve
|
||||
event.pubkey.serialize().to_vec()
|
||||
}
|
||||
|
||||
/// Resolve the effective author pubkey from stored (non-Event) data.
|
||||
///
|
||||
/// REST-created messages are signed by the relay keypair and carry the real
|
||||
/// sender in the first `p` tag. This helper mirrors `effective_author` but
|
||||
/// works with raw bytes + stored tags JSON rather than a `nostr::Event`.
|
||||
fn effective_author_bytes(
|
||||
msg_pubkey: &[u8],
|
||||
tags: &serde_json::Value,
|
||||
relay_pubkey_bytes: &[u8],
|
||||
) -> Vec<u8> {
|
||||
if msg_pubkey == relay_pubkey_bytes {
|
||||
// Relay-signed: real author is in the first p tag.
|
||||
if let Some(tags_arr) = tags.as_array() {
|
||||
for tag in tags_arr {
|
||||
if let Some(arr) = tag.as_array() {
|
||||
if arr.len() >= 2 && arr[0].as_str() == Some("p") {
|
||||
if let Some(hex) = arr[1].as_str() {
|
||||
if let Ok(bytes) = nostr_hex::decode(hex) {
|
||||
if bytes.len() == 32 {
|
||||
return bytes;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
msg_pubkey.to_vec()
|
||||
}
|
||||
|
||||
/// Serialize a slice of reaction summaries to JSON.
|
||||
fn reactions_to_json(reactions: &[sprout_db::reaction::ReactionSummary]) -> serde_json::Value {
|
||||
serde_json::json!(reactions
|
||||
@@ -434,12 +465,16 @@ pub async fn list_messages(
|
||||
// Determine next_cursor from the oldest message in this page.
|
||||
let next_cursor = messages.last().map(|m| m.created_at.timestamp());
|
||||
|
||||
// Compute relay pubkey bytes once for effective-author resolution.
|
||||
let relay_pk_bytes = state.relay_keypair.public_key().serialize().to_vec();
|
||||
|
||||
let result: Vec<serde_json::Value> = messages
|
||||
.iter()
|
||||
.map(|m| {
|
||||
let author = effective_author_bytes(&m.pubkey, &m.tags, &relay_pk_bytes);
|
||||
let mut obj = serde_json::json!({
|
||||
"event_id": nostr_hex::encode(&m.event_id),
|
||||
"pubkey": nostr_hex::encode(&m.pubkey),
|
||||
"pubkey": nostr_hex::encode(&author),
|
||||
"content": m.content,
|
||||
"kind": m.kind,
|
||||
"created_at": m.created_at.timestamp(),
|
||||
@@ -573,9 +608,12 @@ pub async fn get_thread(
|
||||
|
||||
// Serialize root event.
|
||||
let root_created_at = root_event.event.created_at.as_u64() as i64;
|
||||
let relay_pk = state.relay_keypair.public_key();
|
||||
let relay_pk_bytes = relay_pk.serialize().to_vec();
|
||||
let root_author = effective_author(&root_event.event, &relay_pk);
|
||||
let mut root_obj = serde_json::json!({
|
||||
"event_id": root_event.event.id.to_hex(),
|
||||
"pubkey": root_event.event.pubkey.to_hex(),
|
||||
"pubkey": nostr_hex::encode(&root_author),
|
||||
"content": root_event.event.content,
|
||||
"kind": root_event.event.kind.as_u16(),
|
||||
"created_at": root_created_at,
|
||||
@@ -620,12 +658,13 @@ pub async fn get_thread(
|
||||
let reply_objs: Vec<serde_json::Value> = replies
|
||||
.iter()
|
||||
.map(|r| {
|
||||
let reply_author = effective_author_bytes(&r.pubkey, &r.tags, &relay_pk_bytes);
|
||||
let mut obj = serde_json::json!({
|
||||
"event_id": nostr_hex::encode(&r.event_id),
|
||||
"parent_event_id": r.parent_event_id.as_ref().map(nostr_hex::encode),
|
||||
"root_event_id": r.root_event_id.as_ref().map(nostr_hex::encode),
|
||||
"channel_id": r.channel_id.to_string(),
|
||||
"pubkey": nostr_hex::encode(&r.pubkey),
|
||||
"pubkey": nostr_hex::encode(&reply_author),
|
||||
"content": r.content,
|
||||
"kind": r.kind,
|
||||
"depth": r.depth,
|
||||
|
||||
@@ -71,6 +71,7 @@ use axum::{
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::Json,
|
||||
};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
@@ -111,9 +112,11 @@ fn decode_jwt_payload_unverified(
|
||||
/// Extract an authenticated pubkey from the request headers.
|
||||
///
|
||||
/// Auth resolution order:
|
||||
/// 1. `Authorization: Bearer <jwt>` — validated via JWKS when `require_auth_token=true`,
|
||||
/// or decoded unverified (username → derived key) when `require_auth_token=false`.
|
||||
/// 2. `X-Pubkey: <hex>` — accepted only when `require_auth_token=false` (dev mode).
|
||||
/// 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(
|
||||
@@ -122,9 +125,56 @@ pub(crate) async fn extract_auth_pubkey(
|
||||
) -> Result<(nostr::PublicKey, Vec<u8>), (StatusCode, Json<serde_json::Value>)> {
|
||||
let require_auth = state.config.require_auth_token;
|
||||
|
||||
// Try Authorization: Bearer <jwt>
|
||||
// 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 {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//!
|
||||
//! Endpoints:
|
||||
//! GET /api/users/me/profile — get own profile
|
||||
//! PUT /api/users/me/profile — update own profile (display_name, avatar_url)
|
||||
//! PUT /api/users/me/profile — update own profile (display_name, avatar_url, about)
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -19,18 +19,20 @@ use crate::state::AppState;
|
||||
use super::{api_error, extract_auth_pubkey, internal_error};
|
||||
|
||||
/// Request body for updating a user's profile.
|
||||
/// Both fields are optional — at least one must be present.
|
||||
/// All fields are optional — at least one must be present.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateProfileBody {
|
||||
/// New display name for the user, or `None` to leave unchanged.
|
||||
pub display_name: Option<String>,
|
||||
/// New avatar URL for the user, or `None` to leave unchanged.
|
||||
pub avatar_url: Option<String>,
|
||||
/// Short bio or description, or `None` to leave unchanged.
|
||||
pub about: Option<String>,
|
||||
}
|
||||
|
||||
/// `PUT /api/users/me/profile` — update the authenticated user's profile.
|
||||
///
|
||||
/// Body: `{ "display_name": "Alice", "avatar_url": "https://..." }` (both optional, at least one required)
|
||||
/// Body: `{ "display_name": "Alice", "avatar_url": "https://...", "about": "..." }` (all optional, at least one required)
|
||||
/// Returns: `{ "updated": true }`
|
||||
pub async fn update_profile(
|
||||
State(state): State<Arc<AppState>>,
|
||||
@@ -49,17 +51,22 @@ pub async fn update_profile(
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty());
|
||||
let about = body
|
||||
.about
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty());
|
||||
|
||||
if display_name.is_none() && avatar_url.is_none() {
|
||||
if display_name.is_none() && avatar_url.is_none() && about.is_none() {
|
||||
return Err(api_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"at least one of display_name or avatar_url is required",
|
||||
"at least one of display_name, avatar_url, or about is required",
|
||||
));
|
||||
}
|
||||
|
||||
state
|
||||
.db
|
||||
.update_user_profile(&pubkey_bytes, display_name, avatar_url)
|
||||
.update_user_profile(&pubkey_bytes, display_name, avatar_url, about)
|
||||
.await
|
||||
.map_err(|e| internal_error(&format!("db error: {e}")))?;
|
||||
|
||||
@@ -68,7 +75,7 @@ pub async fn update_profile(
|
||||
|
||||
/// `GET /api/users/me/profile` — get the authenticated user's profile.
|
||||
///
|
||||
/// Returns: `{ "pubkey": "<hex>", "display_name": "...", "avatar_url": "...", "nip05_handle": "..." }`
|
||||
/// Returns: `{ "pubkey": "<hex>", "display_name": "...", "avatar_url": "...", "about": "...", "nip05_handle": "..." }`
|
||||
pub async fn get_profile(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
@@ -86,6 +93,7 @@ pub async fn get_profile(
|
||||
"pubkey": nostr_hex::encode(&p.pubkey),
|
||||
"display_name": p.display_name,
|
||||
"avatar_url": p.avatar_url,
|
||||
"about": p.about,
|
||||
"nip05_handle": p.nip05_handle,
|
||||
}))),
|
||||
None => Err(api_error(StatusCode::NOT_FOUND, "user not found")),
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use crate::connection::{AuthState, ConnectionState};
|
||||
@@ -40,6 +41,96 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc<ConnectionState>, state:
|
||||
let auth_svc = Arc::clone(&state.auth);
|
||||
let event_id_hex = event.id.to_hex();
|
||||
|
||||
// Extract the auth_token tag before dispatching — API tokens (sprout_*) must be
|
||||
// intercepted here because verify_auth_event() has no DB access and rejects them.
|
||||
let auth_token = event.tags.iter().find_map(|tag| {
|
||||
let vec = tag.as_slice();
|
||||
if vec.len() >= 2 && vec[0] == "auth_token" {
|
||||
Some(vec[1].to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
if let Some(ref token) = auth_token {
|
||||
if token.starts_with("sprout_") {
|
||||
// ── API token path ──────────────────────────────────────────────
|
||||
// Hash the raw token and look it up in the DB. The relay owns this
|
||||
// path; sprout-auth has no DB access.
|
||||
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) => {
|
||||
warn!(conn_id = %conn_id, error = %e, "API token lookup failed");
|
||||
*conn.auth_state.write().await = AuthState::Failed;
|
||||
conn.send(RelayMessage::ok(
|
||||
&event_id_hex,
|
||||
false,
|
||||
"auth-required: verification failed",
|
||||
));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Reconstruct the owner pubkey from the stored raw bytes.
|
||||
let owner_pubkey = match nostr::PublicKey::from_slice(&record.owner_pubkey) {
|
||||
Ok(pk) => pk,
|
||||
Err(e) => {
|
||||
warn!(conn_id = %conn_id, error = %e, "API token owner pubkey invalid");
|
||||
*conn.auth_state.write().await = AuthState::Failed;
|
||||
conn.send(RelayMessage::ok(
|
||||
&event_id_hex,
|
||||
false,
|
||||
"auth-required: verification failed",
|
||||
));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Verify hash, expiry, and pubkey match via the auth service.
|
||||
match auth_svc.verify_api_token_against_hash(
|
||||
token,
|
||||
&record.token_hash,
|
||||
&owner_pubkey,
|
||||
&event.pubkey,
|
||||
record.expires_at,
|
||||
&record.scopes,
|
||||
) {
|
||||
Ok((pubkey, scopes)) => {
|
||||
info!(conn_id = %conn_id, pubkey = %pubkey.to_hex(), "API token auth successful");
|
||||
// Update last_used_at asynchronously — non-fatal if it fails.
|
||||
let db = state.db.clone();
|
||||
let hash_owned = hash;
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = db.update_token_last_used(&hash_owned).await {
|
||||
warn!("update_token_last_used failed: {e}");
|
||||
}
|
||||
});
|
||||
let auth_ctx = sprout_auth::AuthContext {
|
||||
pubkey,
|
||||
scopes,
|
||||
auth_method: sprout_auth::AuthMethod::Nip42ApiToken,
|
||||
};
|
||||
*conn.auth_state.write().await = AuthState::Authenticated(auth_ctx);
|
||||
conn.send(RelayMessage::ok(&event_id_hex, true, ""));
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(conn_id = %conn_id, error = %e, "API token verification failed");
|
||||
*conn.auth_state.write().await = AuthState::Failed;
|
||||
conn.send(RelayMessage::ok(
|
||||
&event_id_hex,
|
||||
false,
|
||||
"auth-required: verification failed",
|
||||
));
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Okta JWT / pubkey-only path ─────────────────────────────────────────
|
||||
// Non-sprout_ tokens (eyJ* JWTs) and no-token (open-relay) fall through here.
|
||||
match auth_svc
|
||||
.verify_auth_event(event, &challenge, &relay_url)
|
||||
.await
|
||||
|
||||
@@ -507,15 +507,51 @@ pub async fn dispatch_action(
|
||||
action: &ActionDef,
|
||||
_engine: &WorkflowEngine,
|
||||
run_id: Uuid,
|
||||
trigger_ctx: &TriggerContext,
|
||||
) -> Result<StepResult, WorkflowError> {
|
||||
use ActionDef::*;
|
||||
|
||||
match action {
|
||||
SendMessage { text, channel } => {
|
||||
let target = channel.as_deref().unwrap_or("<workflow channel>");
|
||||
info!(run_id = %run_id, step = step_id, "SendMessage → {target}: {text}");
|
||||
// TODO (WF-07): emit kind:40001 event via engine's event emitter.
|
||||
Ok(StepResult::Completed(serde_json::json!({ "sent": true })))
|
||||
// Use explicit channel override if provided; otherwise fall back to
|
||||
// the channel that triggered this workflow run.
|
||||
let channel_id = channel
|
||||
.as_deref()
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or(&trigger_ctx.channel_id);
|
||||
|
||||
info!(
|
||||
run_id = %run_id,
|
||||
step = step_id,
|
||||
channel = %channel_id,
|
||||
"SendMessage → {channel_id}: {text}"
|
||||
);
|
||||
|
||||
if channel_id.is_empty() {
|
||||
return Err(WorkflowError::InvalidDefinition(
|
||||
"SendMessage: no channel_id available (trigger has no channel context and \
|
||||
no channel override was specified)"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
|
||||
#[cfg(feature = "reqwest")]
|
||||
{
|
||||
let result = send_message_impl(channel_id, text).await?;
|
||||
Ok(StepResult::Completed(result))
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "reqwest"))]
|
||||
{
|
||||
warn!(
|
||||
run_id = %run_id,
|
||||
step = step_id,
|
||||
"SendMessage: reqwest feature not enabled, skipping HTTP call"
|
||||
);
|
||||
Ok(StepResult::Completed(
|
||||
serde_json::json!({ "sent": false, "skipped": true }),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
SendDm { to, text } => {
|
||||
@@ -793,6 +829,82 @@ async fn call_webhook_impl(
|
||||
}))
|
||||
}
|
||||
|
||||
// ── send_message implementation (feature-gated) ──────────────────────────────
|
||||
|
||||
/// POST `{"content": text}` to `POST /api/channels/{channel_id}/messages`.
|
||||
///
|
||||
/// Relay base URL is read from `SPROUT_RELAY_BASE_URL` (default: `http://localhost:3000`).
|
||||
///
|
||||
/// Auth strategy (in priority order):
|
||||
/// 1. `SPROUT_API_TOKEN` env var → `Authorization: Bearer <token>` (production)
|
||||
/// 2. `SPROUT_RELAY_PUBKEY` env var → `X-Pubkey: <hex>` (dev mode only,
|
||||
/// requires `SPROUT_REQUIRE_AUTH_TOKEN=false`)
|
||||
///
|
||||
/// NOTE: This implementation uses X-Pubkey auth which only works in dev mode
|
||||
/// (SPROUT_REQUIRE_AUTH_TOKEN=false). For production, the executor needs to
|
||||
/// either: (a) use an API token (SPROUT_API_TOKEN env var), or (b) sign
|
||||
/// events directly and submit via WebSocket. See WF-07.
|
||||
/// TODO(WF-07): Support production auth for workflow-generated messages.
|
||||
///
|
||||
/// This is an internal call — the workflow engine runs inside the relay process,
|
||||
/// so `localhost:3000` is always reachable without SSRF concerns.
|
||||
#[cfg(feature = "reqwest")]
|
||||
async fn send_message_impl(channel_id: &str, text: &str) -> Result<JsonValue, WorkflowError> {
|
||||
use reqwest::Client;
|
||||
use std::time::Duration;
|
||||
|
||||
let base_url = std::env::var("SPROUT_RELAY_BASE_URL")
|
||||
.unwrap_or_else(|_| "http://localhost:3000".to_owned());
|
||||
|
||||
let url = format!("{base_url}/api/channels/{channel_id}/messages");
|
||||
|
||||
let client = Client::builder()
|
||||
.timeout(Duration::from_secs(10))
|
||||
.build()
|
||||
.map_err(|e| WorkflowError::WebhookError(format!("failed to build HTTP client: {e}")))?;
|
||||
|
||||
let mut req = client
|
||||
.post(&url)
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&serde_json::json!({ "content": text }));
|
||||
|
||||
// Attach auth header: prefer API token (production), fall back to X-Pubkey (dev mode).
|
||||
if let Ok(token) = std::env::var("SPROUT_API_TOKEN") {
|
||||
req = req.header("Authorization", format!("Bearer {token}"));
|
||||
} else if let Ok(pubkey) = std::env::var("SPROUT_RELAY_PUBKEY") {
|
||||
req = req.header("X-Pubkey", pubkey);
|
||||
}
|
||||
|
||||
let resp = req
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| WorkflowError::WebhookError(format!("SendMessage HTTP error: {e}")))?;
|
||||
|
||||
let status = resp.status();
|
||||
|
||||
if !status.is_success() {
|
||||
let body = resp
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|_| "<unreadable>".to_owned());
|
||||
return Err(WorkflowError::WebhookError(format!(
|
||||
"SendMessage: relay returned {status} for channel {channel_id}: {body}"
|
||||
)));
|
||||
}
|
||||
|
||||
let body_text = resp.text().await.unwrap_or_else(|_| String::new());
|
||||
|
||||
// Try to parse the response as JSON; fall back to wrapping the raw text.
|
||||
let body_json: JsonValue = serde_json::from_str(&body_text)
|
||||
.unwrap_or_else(|_| serde_json::json!({ "raw": body_text }));
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"sent": true,
|
||||
"status": status.as_u16(),
|
||||
"response": body_json,
|
||||
}))
|
||||
}
|
||||
|
||||
// ── Execution result ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Rich return type from `execute_run` / `execute_from_step`.
|
||||
@@ -997,7 +1109,7 @@ async fn execute_steps(
|
||||
.unwrap_or(engine.config.default_timeout_secs);
|
||||
let dispatch_result = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(timeout_secs),
|
||||
dispatch_action(&step.id, &resolved_action, engine, run_id),
|
||||
dispatch_action(&step.id, &resolved_action, engine, run_id, trigger_ctx),
|
||||
)
|
||||
.await;
|
||||
|
||||
|
||||
@@ -18,14 +18,18 @@ export default defineConfig({
|
||||
projects: [
|
||||
{
|
||||
name: "smoke",
|
||||
testMatch: "**/smoke.spec.ts",
|
||||
testMatch: [
|
||||
"**/smoke.spec.ts",
|
||||
"**/channels.spec.ts",
|
||||
"**/messaging.spec.ts",
|
||||
],
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "integration",
|
||||
testMatch: "**/stream.spec.ts",
|
||||
testMatch: ["**/stream.spec.ts", "**/integration.spec.ts"],
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
},
|
||||
|
||||
125
desktop/tests/e2e/channels.spec.ts
Normal file
125
desktop/tests/e2e/channels.spec.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { installMockBridge } from "../helpers/bridge";
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await installMockBridge(page);
|
||||
});
|
||||
|
||||
test("sidebar shows all channel types", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
await expect(page.getByTestId("app-sidebar")).toBeVisible();
|
||||
|
||||
// Streams
|
||||
const streamList = page.getByTestId("stream-list");
|
||||
await expect(streamList).toContainText("general");
|
||||
await expect(streamList).toContainText("random");
|
||||
await expect(streamList).toContainText("engineering");
|
||||
await expect(streamList).toContainText("agents");
|
||||
|
||||
// Forums
|
||||
const forumList = page.getByTestId("forum-list");
|
||||
await expect(forumList).toContainText("watercooler");
|
||||
await expect(forumList).toContainText("announcements");
|
||||
|
||||
// DMs
|
||||
const dmList = page.getByTestId("dm-list");
|
||||
await expect(dmList).toContainText("alice-tyler");
|
||||
await expect(dmList).toContainText("bob-tyler");
|
||||
});
|
||||
|
||||
test("create stream with name and description", async ({ page }) => {
|
||||
const channelName = `my-new-stream-${Date.now()}`;
|
||||
|
||||
await page.goto("/");
|
||||
await page.getByRole("button", { name: "Create a stream" }).click();
|
||||
await page.getByTestId("create-stream-name").fill(channelName);
|
||||
await page
|
||||
.getByTestId("create-stream-description")
|
||||
.fill("A stream for testing channel creation");
|
||||
await page.getByRole("button", { name: "Create" }).click();
|
||||
|
||||
await expect(page.getByTestId("stream-list")).toContainText(channelName);
|
||||
await expect(page.getByTestId("chat-title")).toHaveText(channelName);
|
||||
});
|
||||
|
||||
test("create stream with special characters", async ({ page }) => {
|
||||
const channelName = `dev ops-${Date.now()}`;
|
||||
|
||||
await page.goto("/");
|
||||
await page.getByRole("button", { name: "Create a stream" }).click();
|
||||
await page.getByTestId("create-stream-name").fill(channelName);
|
||||
await page
|
||||
.getByTestId("create-stream-description")
|
||||
.fill("Stream with spaces and hyphens");
|
||||
await page.getByRole("button", { name: "Create" }).click();
|
||||
|
||||
await expect(page.getByTestId("stream-list")).toContainText(channelName);
|
||||
await expect(page.getByTestId("chat-title")).toHaveText(channelName);
|
||||
});
|
||||
|
||||
test("switch between streams", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
await page.getByTestId("channel-general").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
||||
|
||||
await page.getByTestId("channel-random").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("random");
|
||||
|
||||
await page.getByTestId("channel-engineering").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("engineering");
|
||||
});
|
||||
|
||||
test("switch between channel types", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
// Stream
|
||||
await page.getByTestId("channel-general").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
||||
|
||||
// Forum
|
||||
await page.getByTestId("channel-watercooler").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("watercooler");
|
||||
|
||||
// DM
|
||||
await page.getByTestId("channel-alice-tyler").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("alice-tyler");
|
||||
});
|
||||
|
||||
test("empty channel shows empty state", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
await page.getByTestId("channel-random").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("random");
|
||||
await expect(page.getByTestId("message-empty")).toBeVisible();
|
||||
});
|
||||
|
||||
test("channel with messages shows content", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
await page.getByTestId("channel-general").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
||||
await expect(page.getByTestId("message-timeline")).toContainText(
|
||||
"Welcome to #general",
|
||||
);
|
||||
});
|
||||
|
||||
test("sidebar persists after channel switch", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
await expect(page.getByTestId("app-sidebar")).toBeVisible();
|
||||
|
||||
await page.getByTestId("channel-general").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
||||
await expect(page.getByTestId("app-sidebar")).toBeVisible();
|
||||
|
||||
await page.getByTestId("channel-random").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("random");
|
||||
await expect(page.getByTestId("app-sidebar")).toBeVisible();
|
||||
|
||||
await page.getByTestId("channel-watercooler").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("watercooler");
|
||||
await expect(page.getByTestId("app-sidebar")).toBeVisible();
|
||||
});
|
||||
164
desktop/tests/e2e/integration.spec.ts
Normal file
164
desktop/tests/e2e/integration.spec.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { expect, test, type Browser } from "@playwright/test";
|
||||
|
||||
import { installRelayBridge } from "../helpers/bridge";
|
||||
import { assertRelaySeeded } from "../helpers/seed";
|
||||
|
||||
test.beforeAll(async () => {
|
||||
await assertRelaySeeded();
|
||||
});
|
||||
|
||||
test("create channel and verify in sidebar", async ({ page }) => {
|
||||
const channelName = `integration-e2e-${Date.now()}`;
|
||||
|
||||
await installRelayBridge(page, "tyler");
|
||||
await page.goto("/");
|
||||
await page.getByRole("button", { name: "Create a stream" }).click();
|
||||
await page.getByTestId("create-stream-name").fill(channelName);
|
||||
await page.getByRole("button", { name: "Create" }).click();
|
||||
|
||||
await expect(page.getByTestId("stream-list")).toContainText(channelName);
|
||||
await expect(page.getByTestId("chat-title")).toHaveText(channelName);
|
||||
});
|
||||
|
||||
test("two users see the same channel", async ({
|
||||
browser,
|
||||
}: {
|
||||
browser: Browser;
|
||||
}) => {
|
||||
const channelName = `shared-channel-${Date.now()}`;
|
||||
const contextOne = await browser.newContext();
|
||||
const contextTwo = await browser.newContext();
|
||||
const pageOne = await contextOne.newPage();
|
||||
const pageTwo = await contextTwo.newPage();
|
||||
|
||||
try {
|
||||
await installRelayBridge(pageOne, "tyler");
|
||||
await installRelayBridge(pageTwo, "alice");
|
||||
|
||||
await pageOne.goto("/");
|
||||
await pageOne.getByRole("button", { name: "Create a stream" }).click();
|
||||
await pageOne.getByTestId("create-stream-name").fill(channelName);
|
||||
await pageOne.getByRole("button", { name: "Create" }).click();
|
||||
await expect(pageOne.getByTestId("stream-list")).toContainText(channelName);
|
||||
|
||||
await pageTwo.goto("/");
|
||||
await expect(pageTwo.getByTestId("stream-list")).toContainText(channelName);
|
||||
} finally {
|
||||
await contextOne.close();
|
||||
await contextTwo.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("message delivery across users", async ({
|
||||
browser,
|
||||
}: {
|
||||
browser: Browser;
|
||||
}) => {
|
||||
const message = `Cross-user message ${Date.now()}`;
|
||||
const contextOne = await browser.newContext();
|
||||
const contextTwo = await browser.newContext();
|
||||
const pageOne = await contextOne.newPage();
|
||||
const pageTwo = await contextTwo.newPage();
|
||||
|
||||
try {
|
||||
await installRelayBridge(pageOne, "tyler");
|
||||
await installRelayBridge(pageTwo, "alice");
|
||||
|
||||
await pageOne.goto("/");
|
||||
await pageTwo.goto("/");
|
||||
|
||||
await pageOne.getByTestId("channel-general").click();
|
||||
await pageTwo.getByTestId("channel-general").click();
|
||||
await expect(pageOne.getByTestId("chat-title")).toHaveText("general");
|
||||
await expect(pageTwo.getByTestId("chat-title")).toHaveText("general");
|
||||
|
||||
await pageOne.getByTestId("message-input").fill(message);
|
||||
await pageOne.getByTestId("send-message").click();
|
||||
|
||||
await expect(pageTwo.getByTestId("message-timeline")).toContainText(
|
||||
message,
|
||||
);
|
||||
} finally {
|
||||
await contextOne.close();
|
||||
await contextTwo.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("DM channel appears in sidebar", async ({ page }) => {
|
||||
await installRelayBridge(page, "tyler");
|
||||
await page.goto("/");
|
||||
|
||||
await expect(page.getByTestId("dm-list")).toContainText("alice-tyler");
|
||||
});
|
||||
|
||||
test("send message to DM", async ({ page }) => {
|
||||
const message = `DM message ${Date.now()}`;
|
||||
|
||||
await installRelayBridge(page, "tyler");
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-alice-tyler").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("alice-tyler");
|
||||
|
||||
await page.getByTestId("message-input").fill(message);
|
||||
await page.getByTestId("send-message").click();
|
||||
|
||||
await expect(page.getByTestId("message-timeline")).toContainText(message);
|
||||
});
|
||||
|
||||
test("forum channel appears in sidebar", async ({ page }) => {
|
||||
await installRelayBridge(page, "tyler");
|
||||
await page.goto("/");
|
||||
|
||||
await expect(page.getByTestId("forum-list")).toContainText("watercooler");
|
||||
});
|
||||
|
||||
test("create channel with description", async ({ page }) => {
|
||||
const channelName = `desc-channel-${Date.now()}`;
|
||||
const description = `Description for ${channelName}`;
|
||||
|
||||
await installRelayBridge(page, "tyler");
|
||||
await page.goto("/");
|
||||
await page.getByRole("button", { name: "Create a stream" }).click();
|
||||
await page.getByTestId("create-stream-name").fill(channelName);
|
||||
await page.getByTestId("create-stream-description").fill(description);
|
||||
await page.getByRole("button", { name: "Create" }).click();
|
||||
|
||||
await expect(page.getByTestId("stream-list")).toContainText(channelName);
|
||||
await expect(page.getByTestId("chat-title")).toHaveText(channelName);
|
||||
await expect(page.getByTestId("chat-description")).toContainText(description);
|
||||
});
|
||||
|
||||
test("multiple channels independent", async ({ page }) => {
|
||||
const channelA = `channel-a-${Date.now()}`;
|
||||
const channelB = `channel-b-${Date.now()}`;
|
||||
const messageA = `Message in A ${Date.now()}`;
|
||||
|
||||
await installRelayBridge(page, "tyler");
|
||||
await page.goto("/");
|
||||
|
||||
// Create channel A
|
||||
await page.getByRole("button", { name: "Create a stream" }).click();
|
||||
await page.getByTestId("create-stream-name").fill(channelA);
|
||||
await page.getByRole("button", { name: "Create" }).click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText(channelA);
|
||||
|
||||
// Create channel B
|
||||
await page.getByRole("button", { name: "Create a stream" }).click();
|
||||
await page.getByTestId("create-stream-name").fill(channelB);
|
||||
await page.getByRole("button", { name: "Create" }).click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText(channelB);
|
||||
|
||||
// Navigate to channel A and send a message
|
||||
await page.getByTestId(`channel-${channelA}`).click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText(channelA);
|
||||
await page.getByTestId("message-input").fill(messageA);
|
||||
await page.getByTestId("send-message").click();
|
||||
await expect(page.getByTestId("message-timeline")).toContainText(messageA);
|
||||
|
||||
// Switch to channel B — message from A should not appear
|
||||
await page.getByTestId(`channel-${channelB}`).click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText(channelB);
|
||||
await expect(page.getByTestId("message-timeline")).not.toContainText(
|
||||
messageA,
|
||||
);
|
||||
});
|
||||
164
desktop/tests/e2e/messaging.spec.ts
Normal file
164
desktop/tests/e2e/messaging.spec.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { installMockBridge } from "../helpers/bridge";
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await installMockBridge(page);
|
||||
});
|
||||
|
||||
test("send a message and see it in timeline", async ({ page }) => {
|
||||
const message = `Hello timeline ${Date.now()}`;
|
||||
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-general").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
||||
|
||||
await page.getByTestId("message-input").fill(message);
|
||||
await page.getByTestId("send-message").click();
|
||||
|
||||
await expect(page.getByTestId("message-timeline")).toContainText(message);
|
||||
});
|
||||
|
||||
test("send multiple messages in sequence", async ({ page }) => {
|
||||
const ts = Date.now();
|
||||
const messages = [
|
||||
`First message ${ts}`,
|
||||
`Second message ${ts}`,
|
||||
`Third message ${ts}`,
|
||||
];
|
||||
const input = page.getByTestId("message-input");
|
||||
const sendButton = page.getByTestId("send-message");
|
||||
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-general").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
||||
|
||||
for (const message of messages) {
|
||||
await input.fill(message);
|
||||
await sendButton.click();
|
||||
await expect(page.getByTestId("message-timeline")).toContainText(message);
|
||||
}
|
||||
|
||||
const timeline = page.getByTestId("message-timeline");
|
||||
for (const message of messages) {
|
||||
await expect(timeline).toContainText(message);
|
||||
}
|
||||
});
|
||||
|
||||
test("message input clears after send", async ({ page }) => {
|
||||
const message = `Clear after send ${Date.now()}`;
|
||||
const input = page.getByTestId("message-input");
|
||||
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-general").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
||||
|
||||
await input.fill(message);
|
||||
await expect(input).toHaveValue(message);
|
||||
await page.getByTestId("send-message").click();
|
||||
|
||||
await expect(page.getByTestId("message-timeline")).toContainText(message);
|
||||
await expect(input).toHaveValue("");
|
||||
});
|
||||
|
||||
test("empty message cannot be sent", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-general").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
||||
|
||||
const sendButton = page.getByTestId("send-message");
|
||||
await expect(sendButton).toBeDisabled();
|
||||
});
|
||||
|
||||
test("send message with Enter key", async ({ page }) => {
|
||||
const message = `Enter key send ${Date.now()}`;
|
||||
const input = page.getByTestId("message-input");
|
||||
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-general").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
||||
|
||||
await input.fill(message);
|
||||
await input.press("Enter");
|
||||
|
||||
await expect(page.getByTestId("message-timeline")).toContainText(message);
|
||||
});
|
||||
|
||||
test("messages persist across channel switches", async ({ page }) => {
|
||||
const message = `Persist across switch ${Date.now()}`;
|
||||
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-general").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
||||
|
||||
await page.getByTestId("message-input").fill(message);
|
||||
await page.getByTestId("send-message").click();
|
||||
await expect(page.getByTestId("message-timeline")).toContainText(message);
|
||||
|
||||
await page.getByTestId("channel-random").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("random");
|
||||
|
||||
await page.getByTestId("channel-general").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
||||
await expect(page.getByTestId("message-timeline")).toContainText(message);
|
||||
});
|
||||
|
||||
test("different channels have independent messages", async ({ page }) => {
|
||||
const ts = Date.now();
|
||||
const generalMessage = `General only ${ts}`;
|
||||
const randomMessage = `Random only ${ts}`;
|
||||
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-general").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
||||
await page.getByTestId("message-input").fill(generalMessage);
|
||||
await page.getByTestId("send-message").click();
|
||||
await expect(page.getByTestId("message-timeline")).toContainText(
|
||||
generalMessage,
|
||||
);
|
||||
|
||||
await page.getByTestId("channel-random").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("random");
|
||||
await expect(page.getByTestId("message-timeline")).not.toContainText(
|
||||
generalMessage,
|
||||
);
|
||||
|
||||
await page.getByTestId("message-input").fill(randomMessage);
|
||||
await page.getByTestId("send-message").click();
|
||||
await expect(page.getByTestId("message-timeline")).toContainText(
|
||||
randomMessage,
|
||||
);
|
||||
|
||||
await page.getByTestId("channel-general").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
||||
await expect(page.getByTestId("message-timeline")).toContainText(
|
||||
generalMessage,
|
||||
);
|
||||
await expect(page.getByTestId("message-timeline")).not.toContainText(
|
||||
randomMessage,
|
||||
);
|
||||
});
|
||||
|
||||
test("day divider appears in timeline", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-general").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
||||
|
||||
await expect(page.getByTestId("message-timeline")).toContainText(
|
||||
"Welcome to #general",
|
||||
);
|
||||
await expect(page.getByTestId("message-timeline-day-divider")).toBeVisible();
|
||||
});
|
||||
|
||||
test("send message to DM channel", async ({ page }) => {
|
||||
const message = `DM message ${Date.now()}`;
|
||||
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-alice-tyler").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("alice-tyler");
|
||||
|
||||
await page.getByTestId("message-input").fill(message);
|
||||
await page.getByTestId("send-message").click();
|
||||
|
||||
await expect(page.getByTestId("message-timeline")).toContainText(message);
|
||||
});
|
||||
1
migrations/20260315000001_user_about.sql
Normal file
1
migrations/20260315000001_user_about.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE users ADD COLUMN about TEXT DEFAULT NULL AFTER avatar_url;
|
||||
Reference in New Issue
Block a user