Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/gitlawb-node/src/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ pub mod ipfs;
pub mod issues;
pub mod labels;
pub mod peers;
pub mod profiles;
pub mod protect;
pub mod pulls;
pub mod register;
Expand Down
171 changes: 171 additions & 0 deletions crates/gitlawb-node/src/api/profiles.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
//! Agent profile API handlers.
//!
//! - `PUT /api/v1/profile` — upsert the caller's profile (requires HTTP Signature)
//! - `GET /api/v1/agents/{did}/profile` — read any agent's profile (public)

use axum::{
extract::{Path, State},
http::StatusCode,
Json,
};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};

use crate::auth::AuthenticatedDid;
use crate::pinata;
use crate::state::AppState;

#[derive(Debug, Deserialize)]
pub struct SetProfileRequest {
pub display_name: Option<String>,
pub bio: Option<String>,
pub avatar_url: Option<String>,
pub website: Option<String>,
pub socials: Option<SocialsInput>,
pub pin_to_ipfs: Option<bool>,
}

#[derive(Debug, Deserialize, Serialize)]
pub struct SocialsInput {
pub twitter: Option<String>,
pub github: Option<String>,
pub farcaster: Option<String>,
pub telegram: Option<String>,
}

pub async fn set_profile(
State(state): State<AppState>,
axum::Extension(auth): axum::Extension<AuthenticatedDid>,
Json(req): Json<SetProfileRequest>,
) -> Result<(StatusCode, Json<Value>), (StatusCode, Json<Value>)> {
let did = auth.0;

if let Some(ref bio) = req.bio {
if bio.len() > 280 {
return Err((
StatusCode::BAD_REQUEST,
Json(json!({ "message": "bio must be 280 characters or fewer" })),
));
}
}

if let Some(ref name) = req.display_name {
if name.len() > 50 {
return Err((
StatusCode::BAD_REQUEST,
Json(json!({ "message": "display_name must be 50 characters or fewer" })),
));
}
}

let socials_json = req
.socials
.as_ref()
.map(|s| serde_json::to_string(s).unwrap_or_else(|_| "{}".to_string()));

let result = state
.db
.upsert_profile(
&did,
req.display_name.as_deref(),
req.bio.as_deref(),
req.avatar_url.as_deref(),
req.website.as_deref(),
socials_json.as_deref(),
)
.await;

match result {
Ok(profile) => {
let mut resp = json!({
"did": profile.did,
"display_name": profile.display_name,
"bio": profile.bio,
"avatar_url": profile.avatar_url,
"website": profile.website,
"updated_at": profile.updated_at,
});

if let Some(ref socials_str) = profile.socials {
if let Ok(socials) = serde_json::from_str::<Value>(socials_str) {
resp["socials"] = socials;
}
}

if req.pin_to_ipfs.unwrap_or(false) && !state.config.pinata_jwt.is_empty() {
let profile_json = serde_json::to_vec(&resp).unwrap_or_default();
match pinata::pin_object(
&state.http_client,
&state.config.pinata_upload_url,
&state.config.pinata_jwt,
&format!("profile-{}", &did),
&profile_json,
)
.await
{
Ok(cid) if !cid.is_empty() => {
if let Err(e) = state.db.set_profile_cid(&did, &cid).await {
tracing::warn!(did = %did, err = %e, "failed to store profile CID");
}
resp["profile_cid"] = json!(cid);
}
Ok(_) => {}
Err(e) => {
tracing::warn!(did = %did, err = %e, "IPFS pin failed — profile saved without CID");
}
}
} else if let Some(cid) = profile.profile_cid {
resp["profile_cid"] = json!(cid);
}

Ok((StatusCode::OK, Json(resp)))
}
Err(e) => {
tracing::error!(did = %did, error = %e, "failed to upsert profile");
Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "message": "failed to save profile" })),
))
}
}
}

pub async fn get_profile(
State(state): State<AppState>,
Path(did): Path<String>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
let profile = state.db.get_profile(&did).await.map_err(|e| {
tracing::error!(did = %did, error = %e, "failed to fetch profile");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "message": "failed to fetch profile" })),
)
})?;

match profile {
Some(p) => {
let mut resp = json!({
"did": p.did,
"display_name": p.display_name,
"bio": p.bio,
"avatar_url": p.avatar_url,
"website": p.website,
"profile_cid": p.profile_cid,
"created_at": p.created_at,
"updated_at": p.updated_at,
});

if let Some(ref socials_str) = p.socials {
if let Ok(socials) = serde_json::from_str::<Value>(socials_str) {
resp["socials"] = socials;
}
}

Ok(Json(resp))
}
None => Err((
StatusCode::NOT_FOUND,
Json(json!({ "message": "profile not found" })),
)),
}
}
150 changes: 148 additions & 2 deletions crates/gitlawb-node/src/db/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,19 @@ pub struct AgentRow {
pub last_seen: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProfileRecord {
pub did: String,
pub display_name: Option<String>,
pub bio: Option<String>,
pub avatar_url: Option<String>,
pub website: Option<String>,
pub socials: Option<String>,
pub profile_cid: Option<String>,
pub created_at: String,
pub updated_at: String,
}

// ── Db ────────────────────────────────────────────────────────────────────────

#[derive(Clone)]
Expand Down Expand Up @@ -367,7 +380,8 @@ struct Migration {
stmts: &'static [&'static str],
}

const MIGRATIONS: &[Migration] = &[Migration {
const MIGRATIONS: &[Migration] = &[
Migration {
version: 1,
name: MIGRATION_V1_NAME,
stmts: &[
Expand Down Expand Up @@ -626,7 +640,25 @@ const MIGRATIONS: &[Migration] = &[Migration {
"CREATE INDEX IF NOT EXISTS idx_bounties_repo ON bounties(repo_owner, repo_name)",
"CREATE INDEX IF NOT EXISTS idx_bounties_claimant ON bounties(claimant_did)",
],
}];
},
Migration {
version: 2,
name: "agent_profiles",
stmts: &[
r#"CREATE TABLE IF NOT EXISTS agent_profiles (
did TEXT NOT NULL PRIMARY KEY,
display_name TEXT,
bio TEXT,
avatar_url TEXT,
website TEXT,
socials TEXT,
profile_cid TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)"#,
],
},
];

// ── Repos ─────────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -2356,6 +2388,120 @@ impl Db {
}
}

// ── Agent Profiles ───────────────────────────────────────────────────────────

impl Db {
pub async fn upsert_profile(
&self,
did: &str,
display_name: Option<&str>,
bio: Option<&str>,
avatar_url: Option<&str>,
website: Option<&str>,
socials: Option<&str>,
) -> Result<ProfileRecord> {
let now = Utc::now().to_rfc3339();

// Try update first for existing profiles (merge fields)
let existing = self.get_profile(did).await?;

if let Some(existing) = existing {
let new_name = display_name.or(existing.display_name.as_deref());
let new_bio = bio.or(existing.bio.as_deref());
let new_avatar = avatar_url.or(existing.avatar_url.as_deref());
let new_website = website.or(existing.website.as_deref());
let new_socials = socials.or(existing.socials.as_deref());

sqlx::query(
"UPDATE agent_profiles
SET display_name=$1, bio=$2, avatar_url=$3, website=$4, socials=$5, updated_at=$6
WHERE did=$7",
)
.bind(new_name)
.bind(new_bio)
.bind(new_avatar)
.bind(new_website)
.bind(new_socials)
.bind(&now)
.bind(did)
.execute(&self.pool)
.await?;

Ok(ProfileRecord {
did: did.to_string(),
display_name: new_name.map(String::from),
bio: new_bio.map(String::from),
avatar_url: new_avatar.map(String::from),
website: new_website.map(String::from),
socials: new_socials.map(String::from),
profile_cid: existing.profile_cid,
created_at: existing.created_at,
updated_at: now,
})
} else {
sqlx::query(
"INSERT INTO agent_profiles (did, display_name, bio, avatar_url, website, socials, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)",
)
.bind(did)
.bind(display_name)
.bind(bio)
.bind(avatar_url)
.bind(website)
.bind(socials)
.bind(&now)
.bind(&now)
.execute(&self.pool)
.await?;

Ok(ProfileRecord {
did: did.to_string(),
display_name: display_name.map(String::from),
bio: bio.map(String::from),
avatar_url: avatar_url.map(String::from),
website: website.map(String::from),
socials: socials.map(String::from),
profile_cid: None,
created_at: now.clone(),
updated_at: now,
})
}
}

pub async fn get_profile(&self, did: &str) -> Result<Option<ProfileRecord>> {
let row = sqlx::query(
"SELECT did, display_name, bio, avatar_url, website, socials, profile_cid, created_at, updated_at
FROM agent_profiles
WHERE did = $1 OR did LIKE '%:' || $1",
)
.bind(did)
.fetch_optional(&self.pool)
.await?;

Ok(row.map(|r| ProfileRecord {
did: r.get("did"),
display_name: r.get("display_name"),
bio: r.get("bio"),
avatar_url: r.get("avatar_url"),
website: r.get("website"),
socials: r.get("socials"),
profile_cid: r.get("profile_cid"),
created_at: r.get("created_at"),
updated_at: r.get("updated_at"),
}))
}

pub async fn set_profile_cid(&self, did: &str, cid: &str) -> Result<()> {
sqlx::query("UPDATE agent_profiles SET profile_cid = $1, updated_at = $2 WHERE did = $3")
.bind(cid)
.bind(Utc::now().to_rfc3339())
.bind(did)
.execute(&self.pool)
.await?;
Ok(())
}
}

// ── Tests ─────────────────────────────────────────────────────────────────────
//
// These tests don't require a live Postgres connection. They validate the
Expand Down
12 changes: 10 additions & 2 deletions crates/gitlawb-node/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ use tower_http::trace::{DefaultOnFailure, DefaultOnResponse, TraceLayer};
use tracing::Level;

use crate::api::{
agents, arweave, bounties, certs, changelog, events, ipfs, issues, labels, peers, protect,
pulls, register, replicas, repos, resolve, stars, tasks, webhooks,
agents, arweave, bounties, certs, changelog, events, ipfs, issues, labels, peers, profiles,
protect, pulls, register, replicas, repos, resolve, stars, tasks, webhooks,
};
use crate::auth;
use crate::rate_limit;
Expand Down Expand Up @@ -210,6 +210,12 @@ pub fn build_router(state: AppState) -> Router {
get(bounties::agent_bounty_stats),
);

// ── Profile routes (write — require HTTP Signature) ─────────────────
let profile_write_routes = add_auth_layers(
Router::new().route("/api/v1/profile", axum::routing::put(profiles::set_profile)),
state.clone(),
);

// ── Issue routes (write — require HTTP Signature, no rate limit) ─────
let issue_write_routes = add_auth_layers(
Router::new()
Expand Down Expand Up @@ -290,6 +296,7 @@ pub fn build_router(state: AppState) -> Router {
.route("/api/v1/agents", get(agents::list_agents))
.route("/api/v1/agents/{did}", get(agents::show_agent))
.route("/api/v1/agents/{did}/trust", get(agents::get_trust))
.route("/api/v1/agents/{did}/profile", get(profiles::get_profile))
.route("/api/v1/events/ref-updates", get(events::list_ref_updates))
.route("/api/v1/resolve/{did}", get(resolve::resolve_did))
.route("/api/v1/repos/{owner}/{repo}/pulls", get(pulls::list_prs))
Expand Down Expand Up @@ -356,6 +363,7 @@ pub fn build_router(state: AppState) -> Router {
.merge(task_read_routes)
.merge(bounty_write_routes)
.merge(bounty_read_routes)
.merge(profile_write_routes)
.merge(creation_routes)
.merge(write_routes)
.merge(git_write_routes)
Expand Down
Loading
Loading