use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use vti_common::error::AppError;
use vti_common::store::KeyspaceHandle;
pub const PROFILE_STORAGE_KEY: &[u8] = b"community/profile";
pub const MAX_EXTENSIONS_BYTES: usize = 16 * 1024;
const MAX_NAME_LEN: usize = 200;
const MAX_DESCRIPTION_LEN: usize = 4_000;
const MAX_LOGO_URL_LEN: usize = 2_048;
const MAX_CONTACT_EMAIL_LEN: usize = 320;
fn cap_len(field: &str, value: Option<&str>, max: usize) -> Result<(), AppError> {
if let Some(v) = value
&& v.chars().count() > max
{
return Err(AppError::Validation(format!(
"{field} exceeds {max} characters (got {})",
v.chars().count()
)));
}
Ok(())
}
pub const RELATIONSHIP_IDENTIFIER_DEFAULT: &str = "pairwise";
pub const RELATIONSHIP_IDENTIFIER_FORMS: [&str; 2] = ["attributed", "pairwise"];
fn default_relationship_identifier() -> String {
RELATIONSHIP_IDENTIFIER_DEFAULT.to_string()
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
#[derive(utoipa::ToSchema)]
pub struct PersonhoodGovernance {
#[serde(default)]
pub real_human: bool,
#[serde(default)]
pub single_membership: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub accepted_idvps: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub governance_framework_url: Option<String>,
}
impl PersonhoodGovernance {
pub fn claims_phc(&self) -> bool {
self.real_human && self.single_membership
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
#[derive(utoipa::ToSchema)]
pub struct CommunityProfile {
pub community_did: String,
pub name: String,
pub description: String,
pub logo_url: Option<String>,
pub public_url: Option<String>,
pub contact_email: Option<String>,
pub language: String,
#[serde(default = "default_relationship_identifier")]
pub relationship_identifier_default: String,
#[serde(default)]
pub personhood: PersonhoodGovernance,
pub created_at: DateTime<Utc>,
#[serde(default, skip_serializing_if = "Value::is_null")]
pub extensions: Value,
}
impl CommunityProfile {
pub fn new(community_did: impl Into<String>, name: impl Into<String>) -> Self {
Self {
community_did: community_did.into(),
name: name.into(),
description: String::new(),
logo_url: None,
public_url: None,
contact_email: None,
language: "en".into(),
relationship_identifier_default: RELATIONSHIP_IDENTIFIER_DEFAULT.into(),
personhood: PersonhoodGovernance::default(),
created_at: Utc::now(),
extensions: Value::Null,
}
}
}
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
#[derive(utoipa::ToSchema)]
pub struct CommunityProfileUpdate {
pub name: Option<String>,
pub description: Option<String>,
pub logo_url: Option<Option<String>>,
pub public_url: Option<Option<String>>,
pub contact_email: Option<Option<String>>,
pub language: Option<String>,
pub relationship_identifier_default: Option<String>,
pub personhood: Option<PersonhoodGovernance>,
pub extensions: Option<Value>,
}
impl CommunityProfileUpdate {
pub fn apply(self, profile: &mut CommunityProfile) -> Result<Vec<String>, AppError> {
if let Some(ext) = &self.extensions {
let bytes = serde_json::to_vec(ext).map_err(AppError::Serialization)?;
if bytes.len() > MAX_EXTENSIONS_BYTES {
return Err(AppError::Validation(format!(
"extensions blob exceeds {MAX_EXTENSIONS_BYTES} bytes (got {})",
bytes.len()
)));
}
}
cap_len("name", self.name.as_deref(), MAX_NAME_LEN)?;
cap_len(
"description",
self.description.as_deref(),
MAX_DESCRIPTION_LEN,
)?;
cap_len(
"contactEmail",
self.contact_email.as_ref().and_then(|v| v.as_deref()),
MAX_CONTACT_EMAIL_LEN,
)?;
if let Some(Some(logo)) = self.logo_url.as_ref() {
cap_len("logoUrl", Some(logo), MAX_LOGO_URL_LEN)?;
if !(logo.is_empty() || logo.starts_with("https://") || logo.starts_with("http://")) {
return Err(AppError::Validation(
"logoUrl must be an http(s) URL".into(),
));
}
}
if let Some(form) = self.relationship_identifier_default.as_deref()
&& !RELATIONSHIP_IDENTIFIER_FORMS.contains(&form)
{
return Err(AppError::Validation(format!(
"relationshipIdentifierDefault must be one of {:?} (got {form:?})",
RELATIONSHIP_IDENTIFIER_FORMS
)));
}
if let Some(p) = self.personhood.as_ref()
&& let Some(url) = p.governance_framework_url.as_deref()
&& !(url.starts_with("https://") || url.starts_with("http://"))
{
return Err(AppError::Validation(
"personhood.governanceFrameworkUrl must be an http(s) URL".into(),
));
}
if let Some(p) = self.personhood.as_ref()
&& p.claims_phc()
&& p.accepted_idvps.is_empty()
{
return Err(AppError::Validation(
"personhood claiming both realHuman and singleMembership must name at least \
one entry in acceptedIdvps — a community acting as its own identity-\
verification provider lists its own DID"
.into(),
));
}
let mut changed = Vec::new();
if let Some(personhood) = self.personhood
&& profile.personhood != personhood
{
profile.personhood = personhood;
changed.push("personhood".into());
}
if let Some(form) = self.relationship_identifier_default
&& profile.relationship_identifier_default != form
{
profile.relationship_identifier_default = form;
changed.push("relationshipIdentifierDefault".into());
}
if let Some(name) = self.name
&& profile.name != name
{
profile.name = name;
changed.push("name".into());
}
if let Some(description) = self.description
&& profile.description != description
{
profile.description = description;
changed.push("description".into());
}
if let Some(logo_url) = self.logo_url
&& profile.logo_url != logo_url
{
profile.logo_url = logo_url;
changed.push("logoUrl".into());
}
if let Some(public_url) = self.public_url
&& profile.public_url != public_url
{
profile.public_url = public_url;
changed.push("publicUrl".into());
}
if let Some(contact_email) = self.contact_email
&& profile.contact_email != contact_email
{
profile.contact_email = contact_email;
changed.push("contactEmail".into());
}
if let Some(language) = self.language
&& profile.language != language
{
profile.language = language;
changed.push("language".into());
}
if let Some(extensions) = self.extensions
&& profile.extensions != extensions
{
profile.extensions = extensions;
changed.push("extensions".into());
}
Ok(changed)
}
}
pub async fn load_profile(ks: &KeyspaceHandle) -> Result<Option<CommunityProfile>, AppError> {
ks.get(PROFILE_STORAGE_KEY.to_vec()).await
}
pub async fn store_profile(
ks: &KeyspaceHandle,
profile: &CommunityProfile,
) -> Result<(), AppError> {
ks.insert(PROFILE_STORAGE_KEY.to_vec(), profile).await?;
crate::admin_events::notify(crate::admin_events::Topic::Config);
Ok(())
}
pub(crate) fn profile_field_pairs(
current: Option<&CommunityProfile>,
incoming: &CommunityProfile,
) -> Vec<(&'static str, Option<Value>, Option<Value>)> {
let s = |v: &str| Value::String(v.to_string());
let opt_s = |v: &Option<String>| match v {
Some(s) => Value::String(s.clone()),
None => Value::Null,
};
vec![
(
"communityDid",
current.map(|p| s(&p.community_did)),
Some(s(&incoming.community_did)),
),
("name", current.map(|p| s(&p.name)), Some(s(&incoming.name))),
(
"description",
current.map(|p| s(&p.description)),
Some(s(&incoming.description)),
),
(
"logoUrl",
current.map(|p| opt_s(&p.logo_url)),
Some(opt_s(&incoming.logo_url)),
),
(
"publicUrl",
current.map(|p| opt_s(&p.public_url)),
Some(opt_s(&incoming.public_url)),
),
(
"contactEmail",
current.map(|p| opt_s(&p.contact_email)),
Some(opt_s(&incoming.contact_email)),
),
(
"language",
current.map(|p| s(&p.language)),
Some(s(&incoming.language)),
),
(
"extensions",
current.map(|p| p.extensions.clone()),
Some(incoming.extensions.clone()),
),
]
}
pub(crate) fn profile_changes(
prior: Option<&CommunityProfile>,
incoming: &CommunityProfile,
) -> Vec<vti_common::audit::FieldChange> {
profile_field_pairs(prior, incoming)
.into_iter()
.filter(|(_, old, new)| old != new)
.map(|(field, old, new)| vti_common::audit::FieldChange {
field: field.to_string(),
old,
new,
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use vti_common::config::StoreConfig;
use vti_common::store::Store;
fn temp_ks() -> (KeyspaceHandle, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("tempdir");
let cfg = StoreConfig {
data_dir: dir.path().to_path_buf(),
};
let store = Store::open(&cfg).expect("store");
(store.keyspace("community-test").expect("ks"), dir)
}
fn sample() -> CommunityProfile {
CommunityProfile::new("did:webvh:vtc.example.com:abc", "Example Community")
}
#[tokio::test]
async fn load_returns_none_when_not_initialised() {
let (ks, _dir) = temp_ks();
let got = load_profile(&ks).await.unwrap();
assert!(got.is_none());
}
#[tokio::test]
async fn store_then_load_round_trips() {
let (ks, _dir) = temp_ks();
let p = sample();
store_profile(&ks, &p).await.unwrap();
let back = load_profile(&ks).await.unwrap().unwrap();
assert_eq!(back, p);
}
#[test]
fn apply_no_fields_yields_empty_changeset() {
let mut p = sample();
let snapshot = p.clone();
let changed = CommunityProfileUpdate::default().apply(&mut p).unwrap();
assert!(changed.is_empty());
assert_eq!(p, snapshot);
}
#[test]
fn apply_changes_only_returned_fields() {
let mut p = sample();
let update = CommunityProfileUpdate {
name: Some("Renamed".into()),
description: Some("Now described".into()),
..CommunityProfileUpdate::default()
};
let changed = update.apply(&mut p).unwrap();
assert_eq!(changed, vec!["name", "description"]);
assert_eq!(p.name, "Renamed");
assert_eq!(p.description, "Now described");
}
#[test]
fn apply_omits_unchanged_value_from_changeset() {
let mut p = sample();
let update = CommunityProfileUpdate {
name: Some(p.name.clone()),
..CommunityProfileUpdate::default()
};
let changed = update.apply(&mut p).unwrap();
assert!(changed.is_empty());
}
#[test]
fn apply_handles_optional_field_clears() {
let mut p = sample();
p.logo_url = Some("https://a.example/logo.png".into());
let update = CommunityProfileUpdate {
logo_url: Some(None),
..CommunityProfileUpdate::default()
};
let changed = update.apply(&mut p).unwrap();
assert_eq!(changed, vec!["logoUrl"]);
assert!(p.logo_url.is_none());
}
#[test]
fn rejects_oversized_text_fields() {
for (label, update) in [
(
"name",
CommunityProfileUpdate {
name: Some("x".repeat(MAX_NAME_LEN + 1)),
..Default::default()
},
),
(
"description",
CommunityProfileUpdate {
description: Some("x".repeat(MAX_DESCRIPTION_LEN + 1)),
..Default::default()
},
),
(
"contactEmail",
CommunityProfileUpdate {
contact_email: Some(Some("x".repeat(MAX_CONTACT_EMAIL_LEN + 1))),
..Default::default()
},
),
] {
let mut p = sample();
let err = update
.apply(&mut p)
.expect_err("oversized must be rejected");
assert!(matches!(err, AppError::Validation(_)), "{label}: {err:?}");
}
}
#[test]
fn rejects_non_http_logo_url_but_accepts_https() {
let mut p = sample();
let bad = CommunityProfileUpdate {
logo_url: Some(Some("javascript:alert(1)".into())),
..Default::default()
};
assert!(bad.apply(&mut p).is_err());
let mut p = sample();
let ok = CommunityProfileUpdate {
logo_url: Some(Some("https://cdn.example.com/logo.png".into())),
..Default::default()
};
assert_eq!(ok.apply(&mut p).unwrap(), vec!["logoUrl"]);
}
#[test]
fn extensions_under_limit_apply() {
let mut p = sample();
let blob = json!({ "x": "a".repeat(100) });
let update = CommunityProfileUpdate {
extensions: Some(blob.clone()),
..CommunityProfileUpdate::default()
};
update.apply(&mut p).unwrap();
assert_eq!(p.extensions, blob);
}
#[test]
fn extensions_at_limit_apply() {
let mut p = sample();
let body_len = MAX_EXTENSIONS_BYTES - 10;
let blob = json!({ "k": "a".repeat(body_len) });
let serialised = serde_json::to_vec(&blob).unwrap();
assert!(serialised.len() <= MAX_EXTENSIONS_BYTES);
let update = CommunityProfileUpdate {
extensions: Some(blob),
..CommunityProfileUpdate::default()
};
update.apply(&mut p).unwrap();
}
#[test]
fn extensions_over_limit_rejected_with_validation_error() {
let mut p = sample();
let original_name = p.name.clone();
let huge = json!({ "k": "a".repeat(MAX_EXTENSIONS_BYTES + 10) });
let update = CommunityProfileUpdate {
name: Some("would-have-changed".into()),
extensions: Some(huge),
..CommunityProfileUpdate::default()
};
let err = update.apply(&mut p).expect_err("too large");
assert!(matches!(err, AppError::Validation(_)));
assert_eq!(p.name, original_name, "name must not have been mutated");
}
#[test]
fn profile_default_language_is_en() {
let p = sample();
assert_eq!(p.language, "en");
}
fn governance(real_human: bool, single_membership: bool) -> PersonhoodGovernance {
PersonhoodGovernance {
real_human,
single_membership,
accepted_idvps: vec!["did:webvh:idvp.example".into()],
governance_framework_url: Some("https://acme.example/governance".into()),
}
}
#[test]
fn a_new_community_claims_no_personhood() {
let p = sample();
assert!(!p.personhood.real_human);
assert!(!p.personhood.single_membership);
assert!(!p.personhood.claims_phc());
assert!(p.personhood.accepted_idvps.is_empty());
}
#[test]
fn phc_needs_both_halves() {
assert!(!governance(false, false).claims_phc());
assert!(!governance(true, false).claims_phc());
assert!(!governance(false, true).claims_phc());
assert!(governance(true, true).claims_phc());
}
#[test]
fn a_phc_claim_must_name_an_idvp() {
let mut p = sample();
let err = CommunityProfileUpdate {
personhood: Some(PersonhoodGovernance {
accepted_idvps: vec![],
..governance(true, true)
}),
..CommunityProfileUpdate::default()
}
.apply(&mut p)
.expect_err("a PHC claim with no IDVP must be refused");
assert!(matches!(err, AppError::Validation(_)), "{err:?}");
assert!(
!p.personhood.claims_phc(),
"the refused claim must not have half-applied"
);
}
#[test]
fn a_partial_claim_may_name_no_idvp() {
let mut p = sample();
let changed = CommunityProfileUpdate {
personhood: Some(PersonhoodGovernance {
accepted_idvps: vec![],
..governance(true, false)
}),
..CommunityProfileUpdate::default()
}
.apply(&mut p)
.expect("a non-PHC claim needs no IDVP list");
assert!(changed.contains(&"personhood".to_string()));
assert!(p.personhood.real_human);
}
#[test]
fn the_governance_url_must_be_http() {
let mut p = sample();
let err = CommunityProfileUpdate {
personhood: Some(PersonhoodGovernance {
governance_framework_url: Some("javascript:alert(1)".into()),
..governance(true, true)
}),
..CommunityProfileUpdate::default()
}
.apply(&mut p)
.expect_err("a non-http governance URL must be refused");
assert!(matches!(err, AppError::Validation(_)), "{err:?}");
}
#[test]
fn a_personhood_patch_replaces_rather_than_merges() {
let mut p = sample();
p.personhood = governance(true, true);
CommunityProfileUpdate {
personhood: Some(PersonhoodGovernance {
real_human: true,
single_membership: false,
accepted_idvps: vec![],
governance_framework_url: None,
}),
..CommunityProfileUpdate::default()
}
.apply(&mut p)
.expect("downgrading a claim is always allowed");
assert!(!p.personhood.claims_phc(), "the claim must have dropped");
assert!(
p.personhood.accepted_idvps.is_empty(),
"the old IDVP list must not survive a replacement that omitted it"
);
assert!(p.personhood.governance_framework_url.is_none());
}
#[test]
fn a_profile_without_personhood_still_deserializes() {
let mut raw = serde_json::to_value(sample()).expect("profile -> json");
raw.as_object_mut().expect("object").remove("personhood");
let p: CommunityProfile = serde_json::from_value(raw).expect("pre-field config must load");
assert!(!p.personhood.claims_phc());
}
}