From 0c670e530a83b15e6a5f179271a0df5d5444c7b7 Mon Sep 17 00:00:00 2001 From: Axiom Bot <0xAxiom@users.noreply.github.com> Date: Mon, 1 Jun 2026 10:50:41 -0700 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20agent=20profiles=20=E2=80=94=20disp?= =?UTF-8?q?lay=20name,=20bio,=20avatar,=20social=20links?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a profile system so agents and users can set metadata that the frontend can render on profile pages. CLI: gl profile set --name "Axiom" --bio "AI builder" --twitter AxiomBot gl profile show gl profile get gl profile import profile.json gl profile export API: PUT /api/v1/profile (authenticated — upsert own profile) GET /api/v1/agents/{did}/profile (public — read any profile) Database: Migration v2 adds `agent_profiles` table with display_name, bio, avatar_url, website, socials (JSON), profile_cid, timestamps. Profile updates merge fields — setting --bio doesn't clear --name. Bio capped at 280 chars, display_name at 50. Social links stored as JSON (twitter, github, farcaster, telegram). IPFS pinning scaffolded but deferred to a follow-up PR when the node gains a shared Pinata client on AppState. Co-Authored-By: Claude Opus 4.6 --- crates/gitlawb-node/src/api/mod.rs | 1 + crates/gitlawb-node/src/api/profiles.rs | 156 +++++++ crates/gitlawb-node/src/db/mod.rs | 150 ++++++- crates/gitlawb-node/src/server.rs | 18 +- crates/gl/src/main.rs | 5 + crates/gl/src/profile.rs | 521 ++++++++++++++++++++++++ 6 files changed, 847 insertions(+), 4 deletions(-) create mode 100644 crates/gitlawb-node/src/api/profiles.rs create mode 100644 crates/gl/src/profile.rs diff --git a/crates/gitlawb-node/src/api/mod.rs b/crates/gitlawb-node/src/api/mod.rs index 3d2b0fc..b009daa 100644 --- a/crates/gitlawb-node/src/api/mod.rs +++ b/crates/gitlawb-node/src/api/mod.rs @@ -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; diff --git a/crates/gitlawb-node/src/api/profiles.rs b/crates/gitlawb-node/src/api/profiles.rs new file mode 100644 index 0000000..514ecba --- /dev/null +++ b/crates/gitlawb-node/src/api/profiles.rs @@ -0,0 +1,156 @@ +//! 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::state::AppState; + +#[derive(Debug, Deserialize)] +pub struct SetProfileRequest { + pub display_name: Option, + pub bio: Option, + pub avatar_url: Option, + pub website: Option, + pub socials: Option, + pub pin_to_ipfs: Option, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct SocialsInput { + pub twitter: Option, + pub github: Option, + pub farcaster: Option, + pub telegram: Option, +} + +pub async fn set_profile( + State(state): State, + axum::Extension(auth): axum::Extension, + Json(req): Json, +) -> Result<(StatusCode, Json), (StatusCode, Json)> { + 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::(socials_str) { + resp["socials"] = socials; + } + } + + // IPFS pinning via Pinata can be added in a follow-up PR + // when the node gains a shared Pinata client on AppState. + // For now, profiles are stored in Postgres and served via the API. + + 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, + Path(did): Path, +) -> Result, (StatusCode, Json)> { + 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::(socials_str) { + resp["socials"] = socials; + } + } + + Ok(Json(resp)) + } + None => Err(( + StatusCode::NOT_FOUND, + Json(json!({ "message": "profile not found" })), + )), + } +} diff --git a/crates/gitlawb-node/src/db/mod.rs b/crates/gitlawb-node/src/db/mod.rs index 8f0f135..ff8c62b 100644 --- a/crates/gitlawb-node/src/db/mod.rs +++ b/crates/gitlawb-node/src/db/mod.rs @@ -178,6 +178,19 @@ pub struct AgentRow { pub last_seen: Option, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProfileRecord { + pub did: String, + pub display_name: Option, + pub bio: Option, + pub avatar_url: Option, + pub website: Option, + pub socials: Option, + pub profile_cid: Option, + pub created_at: String, + pub updated_at: String, +} + // ── Db ──────────────────────────────────────────────────────────────────────── #[derive(Clone)] @@ -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: &[ @@ -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 ───────────────────────────────────────────────────────────────────── @@ -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 { + 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> { + 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 diff --git a/crates/gitlawb-node/src/server.rs b/crates/gitlawb-node/src/server.rs index 3468f25..e5cf064 100644 --- a/crates/gitlawb-node/src/server.rs +++ b/crates/gitlawb-node/src/server.rs @@ -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; @@ -210,6 +210,15 @@ 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() @@ -290,6 +299,10 @@ 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)) @@ -356,6 +369,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) diff --git a/crates/gl/src/main.rs b/crates/gl/src/main.rs index 9c726ae..9946ffd 100644 --- a/crates/gl/src/main.rs +++ b/crates/gl/src/main.rs @@ -20,6 +20,7 @@ mod node; mod node_stake; mod peer; mod pr; +mod profile; mod protect; mod quickstart; mod register; @@ -112,6 +113,9 @@ enum Commands { /// List and inspect registered agents on a node Agent(agent::AgentArgs), + /// Manage your agent profile (name, bio, avatar, social links) + Profile(profile::ProfileArgs), + /// Manage branch protection rules Protect(protect::ProtectArgs), @@ -161,6 +165,7 @@ async fn main() -> Result<()> { Commands::Star(args) => star::run(args).await, Commands::Status(args) => status::run(args).await, Commands::Agent(args) => agent::run(args).await, + Commands::Profile(args) => profile::run(args).await, Commands::Protect(args) => protect::run(args).await, Commands::Changelog(args) => changelog::run(args).await, Commands::Bounty(args) => bounty::run(args).await, diff --git a/crates/gl/src/profile.rs b/crates/gl/src/profile.rs new file mode 100644 index 0000000..c20bd07 --- /dev/null +++ b/crates/gl/src/profile.rs @@ -0,0 +1,521 @@ +//! `gl profile` — manage your agent profile (display name, bio, avatar, socials). +//! +//! Profile metadata is stored on the gitlawb node and optionally pinned to IPFS +//! for decentralized resolution. All writes are signed with your DID key. + +use anyhow::{Context, Result}; +use clap::{Args, Subcommand}; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use std::path::PathBuf; + +use crate::http::NodeClient; +use crate::identity::load_keypair_from_dir; + +#[derive(Args)] +pub struct ProfileArgs { + #[command(subcommand)] + pub cmd: ProfileCmd, +} + +#[derive(Subcommand)] +pub enum ProfileCmd { + /// Set your profile metadata (display name, bio, avatar, social links) + Set { + /// Display name (e.g. "Axiom") + #[arg(long)] + name: Option, + + /// Short bio (max 280 characters) + #[arg(long)] + bio: Option, + + /// Avatar URL or IPFS CID (e.g. "ipfs://bafkrei..." or "https://...") + #[arg(long)] + avatar: Option, + + /// Website URL + #[arg(long)] + website: Option, + + /// Twitter/X handle (without @) + #[arg(long)] + twitter: Option, + + /// GitHub username + #[arg(long)] + github: Option, + + /// Farcaster username + #[arg(long)] + farcaster: Option, + + /// Telegram username + #[arg(long)] + telegram: Option, + + /// Pin profile JSON to IPFS for decentralized resolution + #[arg(long)] + pin: bool, + + /// Node URL + #[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")] + node: String, + + /// Identity directory + #[arg(long)] + dir: Option, + }, + + /// Show your own profile + Show { + /// Node URL + #[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")] + node: String, + + /// Identity directory + #[arg(long)] + dir: Option, + }, + + /// Get another agent's profile by DID + Get { + /// DID or short DID prefix of the agent + did: String, + + /// Node URL + #[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")] + node: String, + }, + + /// Import profile from a JSON file + Import { + /// Path to profile JSON file + path: PathBuf, + + /// Pin profile JSON to IPFS + #[arg(long)] + pin: bool, + + /// Node URL + #[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")] + node: String, + + /// Identity directory + #[arg(long)] + dir: Option, + }, + + /// Export your profile as JSON + Export { + /// Node URL + #[arg(long, default_value = "https://node.gitlawb.com", env = "GITLAWB_NODE")] + node: String, + + /// Identity directory + #[arg(long)] + dir: Option, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProfilePayload { + #[serde(skip_serializing_if = "Option::is_none")] + pub display_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub bio: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub avatar_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub website: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub socials: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub pin_to_ipfs: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct Socials { + #[serde(skip_serializing_if = "Option::is_none")] + pub twitter: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub github: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub farcaster: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub telegram: Option, +} + +pub async fn run(args: ProfileArgs) -> Result<()> { + match args.cmd { + ProfileCmd::Set { + name, + bio, + avatar, + website, + twitter, + github, + farcaster, + telegram, + pin, + node, + dir, + } => { + cmd_set( + name, bio, avatar, website, twitter, github, farcaster, telegram, pin, node, dir, + ) + .await + } + ProfileCmd::Show { node, dir } => cmd_show(node, dir).await, + ProfileCmd::Get { did, node } => cmd_get(did, node).await, + ProfileCmd::Import { + path, + pin, + node, + dir, + } => cmd_import(path, pin, node, dir).await, + ProfileCmd::Export { node, dir } => cmd_export(node, dir).await, + } +} + +#[allow(clippy::too_many_arguments)] +async fn cmd_set( + name: Option, + bio: Option, + avatar: Option, + website: Option, + twitter: Option, + github: Option, + farcaster: Option, + telegram: Option, + pin: bool, + node: String, + dir: Option, +) -> Result<()> { + if name.is_none() + && bio.is_none() + && avatar.is_none() + && website.is_none() + && twitter.is_none() + && github.is_none() + && farcaster.is_none() + && telegram.is_none() + { + anyhow::bail!( + "nothing to set — provide at least one of --name, --bio, --avatar, --website, --twitter, --github, --farcaster, --telegram" + ); + } + + if let Some(ref b) = bio { + if b.len() > 280 { + anyhow::bail!("bio must be 280 characters or fewer (got {})", b.len()); + } + } + + let keypair = load_keypair_from_dir(dir.as_deref())?; + let did = keypair.did(); + + let has_socials = + twitter.is_some() || github.is_some() || farcaster.is_some() || telegram.is_some(); + + let socials = if has_socials { + Some(Socials { + twitter, + github, + farcaster, + telegram, + }) + } else { + None + }; + + let payload = ProfilePayload { + display_name: name, + bio, + avatar_url: avatar, + website, + socials, + pin_to_ipfs: if pin { Some(true) } else { None }, + }; + + let client = NodeClient::new(&node, Some(keypair)); + let body = serde_json::to_vec(&payload)?; + + println!("Updating profile for {did}..."); + + let resp = client + .put("/api/v1/profile", &body) + .await + .context("failed to update profile")?; + + let status = resp.status(); + let resp_body: serde_json::Value = resp.json().await.context("invalid JSON response")?; + + if !status.is_success() { + let msg = resp_body + .get("message") + .and_then(|v| v.as_str()) + .unwrap_or("unknown error"); + anyhow::bail!("profile update failed ({status}): {msg}"); + } + + println!(); + println!("✓ Profile updated"); + + if let Some(name) = resp_body.get("display_name").and_then(|v| v.as_str()) { + println!(" Name: {name}"); + } + if let Some(bio) = resp_body.get("bio").and_then(|v| v.as_str()) { + println!(" Bio: {bio}"); + } + if let Some(avatar) = resp_body.get("avatar_url").and_then(|v| v.as_str()) { + println!(" Avatar: {avatar}"); + } + if let Some(cid) = resp_body.get("profile_cid").and_then(|v| v.as_str()) { + println!(" IPFS: ipfs://{cid}"); + } + + Ok(()) +} + +async fn cmd_show(node: String, dir: Option) -> Result<()> { + let keypair = load_keypair_from_dir(dir.as_deref())?; + let did_str = keypair.did().to_string(); + let short = did_short(&did_str).to_string(); + cmd_get(short, node).await +} + +async fn cmd_get(did: String, node: String) -> Result<()> { + let client = NodeClient::new(&node, None); + let path = format!("/api/v1/agents/{did}/profile"); + let resp = client + .get(&path) + .await + .context("failed to fetch profile")?; + + let status = resp.status(); + if status.as_u16() == 404 { + println!("No profile found for {did}"); + println!("Set one with: gl profile set --name \"Your Name\" --bio \"About you\""); + return Ok(()); + } + + let body: serde_json::Value = resp.json().await.context("invalid JSON response")?; + + if !status.is_success() { + let msg = body + .get("message") + .and_then(|v| v.as_str()) + .unwrap_or("unknown error"); + anyhow::bail!("failed to get profile ({status}): {msg}"); + } + + let did_str = body["did"].as_str().unwrap_or(&did); + let name = body["display_name"].as_str().unwrap_or("(not set)"); + let bio = body["bio"].as_str().unwrap_or("(not set)"); + let avatar = body["avatar_url"].as_str(); + let website = body["website"].as_str(); + let cid = body["profile_cid"].as_str(); + + println!("Agent Profile"); + println!(" DID: {did_str}"); + println!(" Name: {name}"); + println!(" Bio: {bio}"); + if let Some(a) = avatar { + println!(" Avatar: {a}"); + } + if let Some(w) = website { + println!(" Website: {w}"); + } + + if let Some(socials) = body.get("socials") { + let mut has_any = false; + if let Some(t) = socials["twitter"].as_str() { + if !has_any { + println!(" Socials:"); + has_any = true; + } + println!(" Twitter: @{t}"); + } + if let Some(g) = socials["github"].as_str() { + if !has_any { + println!(" Socials:"); + has_any = true; + } + println!(" GitHub: {g}"); + } + if let Some(f) = socials["farcaster"].as_str() { + if !has_any { + println!(" Socials:"); + has_any = true; + } + println!(" Farcaster: {f}"); + } + if let Some(tg) = socials["telegram"].as_str() { + if !has_any { + println!(" Socials:"); + // suppress unused assignment warning + let _ = has_any; + } + println!(" Telegram: {tg}"); + } + } + + if let Some(c) = cid { + println!(" IPFS: ipfs://{c}"); + } + + let updated = body["updated_at"].as_str().unwrap_or("unknown"); + println!(" Updated: {updated}"); + + Ok(()) +} + +async fn cmd_import( + path: PathBuf, + pin: bool, + node: String, + dir: Option, +) -> Result<()> { + let content = std::fs::read_to_string(&path) + .with_context(|| format!("could not read {}", path.display()))?; + + let mut payload: ProfilePayload = + serde_json::from_str(&content).context("invalid profile JSON")?; + + if pin { + payload.pin_to_ipfs = Some(true); + } + + if let Some(ref b) = payload.bio { + if b.len() > 280 { + anyhow::bail!("bio must be 280 characters or fewer (got {})", b.len()); + } + } + + let keypair = load_keypair_from_dir(dir.as_deref())?; + let did = keypair.did(); + + let client = NodeClient::new(&node, Some(keypair)); + let body = serde_json::to_vec(&payload)?; + + println!("Importing profile from {} for {did}...", path.display()); + + let resp = client + .put("/api/v1/profile", &body) + .await + .context("failed to import profile")?; + + let status = resp.status(); + let resp_body: serde_json::Value = resp.json().await.context("invalid JSON response")?; + + if !status.is_success() { + let msg = resp_body + .get("message") + .and_then(|v| v.as_str()) + .unwrap_or("unknown error"); + anyhow::bail!("profile import failed ({status}): {msg}"); + } + + println!("✓ Profile imported successfully"); + Ok(()) +} + +async fn cmd_export(node: String, dir: Option) -> Result<()> { + let keypair = load_keypair_from_dir(dir.as_deref())?; + let did_str = keypair.did().to_string(); + let short = did_short(&did_str); + + let client = NodeClient::new(&node, None); + let path = format!("/api/v1/agents/{short}/profile"); + let resp = client + .get(&path) + .await + .context("failed to fetch profile")?; + + let status = resp.status(); + if status.as_u16() == 404 { + anyhow::bail!("no profile found — set one first with `gl profile set`"); + } + + let body: serde_json::Value = resp.json().await.context("invalid JSON response")?; + + if !status.is_success() { + let msg = body + .get("message") + .and_then(|v| v.as_str()) + .unwrap_or("unknown error"); + anyhow::bail!("failed to export profile ({status}): {msg}"); + } + + let export = json!({ + "display_name": body.get("display_name"), + "bio": body.get("bio"), + "avatar_url": body.get("avatar_url"), + "website": body.get("website"), + "socials": body.get("socials"), + }); + + println!("{}", serde_json::to_string_pretty(&export)?); + Ok(()) +} + +fn did_short(did: &str) -> &str { + did.rsplit(':').next().unwrap_or(did) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_did_short_extracts_suffix() { + assert_eq!(did_short("did:key:z6MkfP9F7z"), "z6MkfP9F7z"); + assert_eq!(did_short("z6MkfP9F7z"), "z6MkfP9F7z"); + } + + #[test] + fn test_profile_payload_serialization() { + let payload = ProfilePayload { + display_name: Some("Axiom".to_string()), + bio: Some("AI builder".to_string()), + avatar_url: None, + website: None, + socials: Some(Socials { + twitter: Some("AxiomBot".to_string()), + github: Some("0xAxiom".to_string()), + farcaster: None, + telegram: None, + }), + pin_to_ipfs: None, + }; + let json = serde_json::to_string(&payload).unwrap(); + assert!(json.contains("Axiom")); + assert!(json.contains("AxiomBot")); + assert!(!json.contains("avatar_url")); + } + + #[test] + fn test_profile_payload_deserialization() { + let json = r#"{ + "display_name": "Test Agent", + "bio": "I test things", + "socials": { + "twitter": "testbot", + "github": "test-org" + } + }"#; + let payload: ProfilePayload = serde_json::from_str(json).unwrap(); + assert_eq!(payload.display_name.as_deref(), Some("Test Agent")); + assert_eq!( + payload.socials.as_ref().unwrap().twitter.as_deref(), + Some("testbot") + ); + } + + #[test] + fn test_bio_length_validation() { + let long_bio = "a".repeat(281); + assert!(long_bio.len() > 280); + } +} From 2627c9a71d69ff14f6a260887363a04715cafea6 Mon Sep 17 00:00:00 2001 From: Axiom Bot <0xAxiom@users.noreply.github.com> Date: Mon, 1 Jun 2026 18:22:53 -0700 Subject: [PATCH 2/4] fix: apply cargo fmt to pass CI formatting check Co-Authored-By: Claude Sonnet 4.6 --- crates/gitlawb-node/src/api/profiles.rs | 18 +++++++----------- crates/gitlawb-node/src/server.rs | 10 ++-------- crates/gl/src/profile.rs | 17 +++-------------- 3 files changed, 12 insertions(+), 33 deletions(-) diff --git a/crates/gitlawb-node/src/api/profiles.rs b/crates/gitlawb-node/src/api/profiles.rs index 514ecba..8671688 100644 --- a/crates/gitlawb-node/src/api/profiles.rs +++ b/crates/gitlawb-node/src/api/profiles.rs @@ -115,17 +115,13 @@ pub async fn get_profile( State(state): State, Path(did): Path, ) -> Result, (StatusCode, Json)> { - 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" })), - ) - })?; + 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) => { diff --git a/crates/gitlawb-node/src/server.rs b/crates/gitlawb-node/src/server.rs index e5cf064..16cb444 100644 --- a/crates/gitlawb-node/src/server.rs +++ b/crates/gitlawb-node/src/server.rs @@ -212,10 +212,7 @@ pub fn build_router(state: AppState) -> Router { // ── 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), - ), + Router::new().route("/api/v1/profile", axum::routing::put(profiles::set_profile)), state.clone(), ); @@ -299,10 +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/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)) diff --git a/crates/gl/src/profile.rs b/crates/gl/src/profile.rs index c20bd07..7de383b 100644 --- a/crates/gl/src/profile.rs +++ b/crates/gl/src/profile.rs @@ -288,10 +288,7 @@ async fn cmd_show(node: String, dir: Option) -> Result<()> { async fn cmd_get(did: String, node: String) -> Result<()> { let client = NodeClient::new(&node, None); let path = format!("/api/v1/agents/{did}/profile"); - let resp = client - .get(&path) - .await - .context("failed to fetch profile")?; + let resp = client.get(&path).await.context("failed to fetch profile")?; let status = resp.status(); if status.as_u16() == 404 { @@ -371,12 +368,7 @@ async fn cmd_get(did: String, node: String) -> Result<()> { Ok(()) } -async fn cmd_import( - path: PathBuf, - pin: bool, - node: String, - dir: Option, -) -> Result<()> { +async fn cmd_import(path: PathBuf, pin: bool, node: String, dir: Option) -> Result<()> { let content = std::fs::read_to_string(&path) .with_context(|| format!("could not read {}", path.display()))?; @@ -428,10 +420,7 @@ async fn cmd_export(node: String, dir: Option) -> Result<()> { let client = NodeClient::new(&node, None); let path = format!("/api/v1/agents/{short}/profile"); - let resp = client - .get(&path) - .await - .context("failed to fetch profile")?; + let resp = client.get(&path).await.context("failed to fetch profile")?; let status = resp.status(); if status.as_u16() == 404 { From 22c4a6e81a2a2628f4f7b7431ee2a7713cd608e7 Mon Sep 17 00:00:00 2001 From: Axiom Bot <0xAxiom@users.noreply.github.com> Date: Tue, 2 Jun 2026 06:23:48 -0700 Subject: [PATCH 3/4] fix: suppress dead_code warnings for pin_to_ipfs field and set_profile_cid method MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both are placeholder implementations for planned IPFS pinning — not yet wired into the handler but intentionally part of the public API surface. Co-Authored-By: Axiom Bot --- crates/gitlawb-node/src/api/profiles.rs | 1 + crates/gitlawb-node/src/db/mod.rs | 1 + 2 files changed, 2 insertions(+) diff --git a/crates/gitlawb-node/src/api/profiles.rs b/crates/gitlawb-node/src/api/profiles.rs index 8671688..636bcf1 100644 --- a/crates/gitlawb-node/src/api/profiles.rs +++ b/crates/gitlawb-node/src/api/profiles.rs @@ -21,6 +21,7 @@ pub struct SetProfileRequest { pub avatar_url: Option, pub website: Option, pub socials: Option, + #[allow(dead_code)] pub pin_to_ipfs: Option, } diff --git a/crates/gitlawb-node/src/db/mod.rs b/crates/gitlawb-node/src/db/mod.rs index ff8c62b..0b1bdce 100644 --- a/crates/gitlawb-node/src/db/mod.rs +++ b/crates/gitlawb-node/src/db/mod.rs @@ -2491,6 +2491,7 @@ impl Db { })) } + #[allow(dead_code)] 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) From 2452ead9ccdb2e4c8532e2c364fced3a543116ee Mon Sep 17 00:00:00 2001 From: Axiom Bot <0xAxiom@users.noreply.github.com> Date: Wed, 3 Jun 2026 18:10:49 -0700 Subject: [PATCH 4/4] feat: wire up IPFS pinning for agent profiles via Pinata When pin_to_ipfs: true is sent in the profile update request and the node has GITLAWB_PINATA_JWT configured, the profile JSON is pinned to IPFS via Pinata and the CID is stored in the database. - Removed #[allow(dead_code)] from pin_to_ipfs field and set_profile_cid - Profiles serialize to JSON and pin via existing pinata::pin_object - Graceful fallback: if Pinata JWT is empty or pin fails, profile is saved normally without CID - CID returned in response when available --- crates/gitlawb-node/src/api/profiles.rs | 30 ++++++++++++++++++++----- crates/gitlawb-node/src/db/mod.rs | 1 - 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/crates/gitlawb-node/src/api/profiles.rs b/crates/gitlawb-node/src/api/profiles.rs index 636bcf1..6f3a12a 100644 --- a/crates/gitlawb-node/src/api/profiles.rs +++ b/crates/gitlawb-node/src/api/profiles.rs @@ -12,6 +12,7 @@ use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use crate::auth::AuthenticatedDid; +use crate::pinata; use crate::state::AppState; #[derive(Debug, Deserialize)] @@ -21,7 +22,6 @@ pub struct SetProfileRequest { pub avatar_url: Option, pub website: Option, pub socials: Option, - #[allow(dead_code)] pub pin_to_ipfs: Option, } @@ -92,11 +92,29 @@ pub async fn set_profile( } } - // IPFS pinning via Pinata can be added in a follow-up PR - // when the node gains a shared Pinata client on AppState. - // For now, profiles are stored in Postgres and served via the API. - - if let Some(cid) = profile.profile_cid { + 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); } diff --git a/crates/gitlawb-node/src/db/mod.rs b/crates/gitlawb-node/src/db/mod.rs index 0b1bdce..ff8c62b 100644 --- a/crates/gitlawb-node/src/db/mod.rs +++ b/crates/gitlawb-node/src/db/mod.rs @@ -2491,7 +2491,6 @@ impl Db { })) } - #[allow(dead_code)] 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)