Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
156 changes: 156 additions & 0 deletions crates/gitlawb-node/src/api/profiles.rs
Original file line number Diff line number Diff line change
@@ -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<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;
}
}

// 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<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
18 changes: 16 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,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()
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading