use std::collections::{BTreeMap, BTreeSet};
use base64::Engine as _;
use chrono::{DateTime, Utc};
use serde_json::Value;
use vti_common::error::AppError;
use vti_common::store::{Batch, KeyspaceHandle};
use super::fields::{DERIVED_KEYS, MEMBER_CHOOSABLE_DERIVED};
use super::model::{
AppliedUpdate, Audience, Card, Discoverability, FieldDef, FieldSource, FieldState,
Introductions, Listing, SearchMode, Settings,
};
pub const MAX_CARD_VALUE_BYTES: usize = 16 * 1024;
fn card_key(entry_id: &str) -> Vec<u8> {
format!("card:{entry_id}").into_bytes()
}
fn index_key(did: &str) -> Vec<u8> {
format!("entry:{did}").into_bytes()
}
pub fn new_entry_id() -> String {
let mut bytes = [0u8; 16];
rand::fill(&mut bytes);
format!(
"dir_{}",
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
)
}
pub async fn get(ks: &KeyspaceHandle, entry_id: &str) -> Result<Option<Card>, AppError> {
ks.get(card_key(entry_id)).await
}
pub async fn entry_of(ks: &KeyspaceHandle, did: &str) -> Result<Option<String>, AppError> {
Ok(ks
.get_raw(index_key(did))
.await?
.and_then(|b| String::from_utf8(b).ok()))
}
pub async fn by_did(ks: &KeyspaceHandle, did: &str) -> Result<Option<Card>, AppError> {
match entry_of(ks, did).await? {
Some(id) => get(ks, &id).await,
None => Ok(None),
}
}
pub async fn all(ks: &KeyspaceHandle) -> Result<Vec<Card>, AppError> {
ks.prefix_iter_raw(b"card:".to_vec())
.await?
.into_iter()
.map(|(_, raw)| {
serde_json::from_slice(&raw).map_err(|e| {
AppError::Internal(format!("stored directory card is unreadable: {e}"))
})
})
.collect()
}
pub fn fresh(did: &str, settings: &Settings, own_label: Option<&str>, now: DateTime<Utc>) -> Card {
let mut values = BTreeMap::new();
if let Some(label) = own_label
&& !label.trim().is_empty()
&& label.chars().count() <= 64
&& !label.contains('\n')
{
values.insert("displayName".to_string(), Value::String(label.to_string()));
}
Card {
entry_id: new_entry_id(),
did: did.to_string(),
values,
visibility: BTreeMap::new(),
discoverability: Discoverability {
listing: settings.default_listing,
search: SearchMode::ByName,
external: false,
},
introductions: Introductions::Anyone,
group_audience: None,
revision: 0,
updated_at: now,
last_update: None,
}
}
pub async fn ensure(
ks: &KeyspaceHandle,
did: &str,
settings: &Settings,
own_label: Option<&str>,
) -> Result<Card, AppError> {
if let Some(card) = by_did(ks, did).await? {
return Ok(card);
}
let _guard = super::WRITE_LOCK.lock().await;
if let Some(card) = by_did(ks, did).await? {
return Ok(card);
}
let card = fresh(did, settings, own_label, Utc::now());
let mut batch = Batch::new();
batch
.require_absent(index_key(did))
.require_absent(card_key(&card.entry_id))
.insert(card_key(&card.entry_id), &card)?
.insert_raw(index_key(did), card.entry_id.clone().into_bytes());
if !ks.apply_batch(batch).await?.is_applied() {
return Err(AppError::Conflict(
"a directory card was created for this member concurrently; read it again".into(),
));
}
Ok(card)
}
async fn store_if_revision(
ks: &KeyspaceHandle,
card: &Card,
expected_revision: u64,
) -> Result<bool, AppError> {
let Some(current) = get(ks, &card.entry_id).await? else {
return Ok(false);
};
if current.revision != expected_revision || current.did != card.did {
return Ok(false);
}
ks.insert(card_key(&card.entry_id), card).await?;
Ok(true)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CardError {
Superseded {
revision: u64,
},
FieldUnknown(String),
FieldRetired(String),
FieldNotWritable(String),
ValueInvalid {
field: String,
reason: String,
},
AboveCeiling {
field: String,
ceiling: Audience,
},
NotAvailable,
NeedsMembershipDid,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Writer {
Member,
Delegated,
Admin,
}
#[derive(Debug, Clone, Default)]
pub struct MemberUpdate {
pub values: BTreeMap<String, Option<Value>>,
pub visibility: BTreeMap<String, Option<Audience>>,
pub discoverability: Option<Discoverability>,
pub introductions: Option<Introductions>,
pub group_audience: Option<Option<Vec<String>>>,
pub expected_revision: Option<u64>,
}
impl MemberUpdate {
pub fn from_payload(p: &Value) -> Result<Self, AppError> {
let bad = |what: &str| AppError::Validation(format!("card/update: unreadable {what}"));
let mut out = MemberUpdate::default();
if let Some(v) = p.get("values").and_then(Value::as_object) {
for (k, val) in v {
out.values
.insert(k.clone(), (!val.is_null()).then(|| val.clone()));
}
}
if let Some(v) = p.get("visibility").and_then(Value::as_object) {
for (k, a) in v {
let a = match a {
Value::Null => None,
Value::String(s) => Some(Audience::parse(s).ok_or_else(|| bad("audience"))?),
_ => return Err(bad("audience")),
};
out.visibility.insert(k.clone(), a);
}
}
if let Some(d) = p.get("discoverability") {
out.discoverability =
Some(serde_json::from_value(d.clone()).map_err(|_| bad("discoverability"))?);
}
if let Some(i) = p.get("introductions") {
out.introductions =
Some(serde_json::from_value(i.clone()).map_err(|_| bad("introductions"))?);
}
if let Some(g) = p.get("groupAudience") {
out.group_audience = Some(if g.is_null() {
None
} else {
Some(serde_json::from_value(g.clone()).map_err(|_| bad("groupAudience"))?)
});
}
out.expected_revision = p.get("expectedRevision").and_then(Value::as_u64);
Ok(out)
}
}
#[derive(Debug, Clone)]
pub struct Applied {
pub card: Card,
pub changed: Vec<String>,
pub widens: bool,
}
fn schema_map(schema: &[FieldDef]) -> BTreeMap<&str, &FieldDef> {
schema.iter().map(|f| (f.key.as_str(), f)).collect()
}
fn check_field<'a>(
schema: &BTreeMap<&str, &'a FieldDef>,
key: &str,
) -> Result<&'a FieldDef, CardError> {
let f = schema
.get(key)
.copied()
.ok_or_else(|| CardError::FieldUnknown(key.into()))?;
if f.state != FieldState::Active {
return Err(CardError::FieldRetired(key.into()));
}
Ok(f)
}
pub fn apply_member_update(
card: &Card,
schema: &[FieldDef],
update: &MemberUpdate,
writer: Writer,
document_id: &str,
issued_at: DateTime<Utc>,
now: DateTime<Utc>,
) -> Result<Applied, CardError> {
if card
.last_update
.as_ref()
.is_some_and(|last| last.issued_at > issued_at)
|| update.expected_revision.is_some_and(|r| r != card.revision)
{
return Err(CardError::Superseded {
revision: card.revision,
});
}
let schema = schema_map(schema);
let mut next = card.clone();
let mut changed = BTreeSet::new();
let mut widens = false;
for (key, value) in &update.values {
let f = check_field(&schema, key)?;
if f.source != FieldSource::Member {
return Err(CardError::FieldNotWritable(key.clone()));
}
match value {
None => {
next.values.remove(key);
}
Some(v) => {
super::values::check(f, v).map_err(|reason| CardError::ValueInvalid {
field: key.clone(),
reason,
})?;
if card.values.get(key) != Some(v) {
widens = true;
}
next.values.insert(key.clone(), v.clone());
}
}
changed.insert(key.clone());
}
for (key, audience) in &update.visibility {
let f = check_field(&schema, key)?;
if f.source == FieldSource::Derived && key != MEMBER_CHOOSABLE_DERIVED {
return Err(CardError::FieldNotWritable(key.clone()));
}
let before = card.chosen(f);
match audience {
None => {
next.visibility.remove(key);
}
Some(a) => {
if !a.within(f.ceiling) {
return Err(CardError::AboveCeiling {
field: key.clone(),
ceiling: f.ceiling,
});
}
if *a == Audience::Public && writer == Writer::Delegated {
return Err(CardError::NeedsMembershipDid);
}
next.visibility.insert(key.clone(), *a);
}
}
if next.chosen(f).reaches_beyond(before) {
widens = true;
}
changed.insert(key.clone());
}
if let Some(d) = update.discoverability {
if d.external {
return Err(CardError::NotAvailable);
}
let was = card.discoverability;
if d.listing.rank() > was.listing.rank() || d.search.rank() > was.search.rank() {
widens = true;
}
next.discoverability = d;
}
if let Some(i) = update.introductions {
let rank = |i: Introductions| match i {
Introductions::Off => 0,
Introductions::Groups | Introductions::Connections => 1,
Introductions::Anyone => 2,
};
if rank(i) > rank(card.introductions)
|| (rank(i) == 1 && i != card.introductions && rank(card.introductions) == 1)
{
widens = true;
}
next.introductions = i;
}
if let Some(groups) = &update.group_audience {
if groups.is_none() && card.group_audience.is_some() {
widens = true;
}
if let (Some(new), Some(old)) = (groups, &card.group_audience)
&& new.iter().any(|g| !old.contains(g))
{
widens = true;
}
next.group_audience = groups.clone();
}
check_size(&next)?;
next.revision = card.revision + 1;
next.updated_at = now;
next.last_update = Some(AppliedUpdate {
document_id: document_id.to_string(),
issued_at,
});
Ok(Applied {
card: next,
changed: changed.into_iter().collect(),
widens,
})
}
pub fn apply_assertion(
card: &Card,
schema: &[FieldDef],
values: &BTreeMap<String, Option<Value>>,
now: DateTime<Utc>,
) -> Result<Applied, CardError> {
let schema = schema_map(schema);
let mut next = card.clone();
let mut changed = Vec::new();
for (key, value) in values {
let f = check_field(&schema, key)?;
if f.source != FieldSource::Admin {
return Err(CardError::FieldNotWritable(key.clone()));
}
match value {
None => {
next.values.remove(key);
}
Some(v) => {
super::values::check(f, v).map_err(|reason| CardError::ValueInvalid {
field: key.clone(),
reason,
})?;
next.values.insert(key.clone(), v.clone());
}
}
changed.push(key.clone());
}
check_size(&next)?;
next.revision = card.revision + 1;
next.updated_at = now;
Ok(Applied {
card: next,
changed,
widens: true,
})
}
fn check_size(card: &Card) -> Result<(), CardError> {
let bytes = serde_json::to_vec(&card.values)
.map(|v| v.len())
.unwrap_or(usize::MAX);
if bytes > MAX_CARD_VALUE_BYTES {
return Err(CardError::ValueInvalid {
field: "values".into(),
reason: format!("a card holds at most {MAX_CARD_VALUE_BYTES} bytes of values"),
});
}
Ok(())
}
pub async fn commit(
ks: &KeyspaceHandle,
before: &Card,
applied: &Applied,
) -> Result<bool, AppError> {
let _guard = super::WRITE_LOCK.lock().await;
store_if_revision(ks, &applied.card, before.revision).await
}
pub async fn rotate(ks: &KeyspaceHandle, old_did: &str, new_did: &str) -> Result<(), AppError> {
let _guard = super::WRITE_LOCK.lock().await;
let Some(entry_id) = entry_of(ks, old_did).await? else {
return Ok(());
};
let Some(mut card) = get(ks, &entry_id).await? else {
ks.remove(index_key(old_did)).await?;
return Ok(());
};
card.did = new_did.to_string();
let mut batch = Batch::new();
batch
.require_absent(index_key(new_did))
.remove(index_key(old_did))
.insert_raw(index_key(new_did), entry_id.clone().into_bytes())
.insert(card_key(&entry_id), &card)?;
if !ks.apply_batch(batch).await?.is_applied() {
return Err(AppError::Conflict(format!(
"{new_did} already has a directory entry; the rotation cannot carry the card"
)));
}
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Departure {
Erase,
Keep,
}
pub async fn on_departure(ks: &KeyspaceHandle, did: &str, how: Departure) -> Result<(), AppError> {
if how == Departure::Keep {
return Ok(());
}
let _guard = super::WRITE_LOCK.lock().await;
if let Some(entry_id) = entry_of(ks, did).await? {
let mut batch = Batch::new();
batch.remove(card_key(&entry_id)).remove(index_key(did));
ks.apply_batch(batch).await?;
}
Ok(())
}
pub async fn purge_field(ks: &KeyspaceHandle, field: &str) -> Result<usize, AppError> {
let _guard = super::WRITE_LOCK.lock().await;
let mut purged = 0;
for mut card in all(ks).await? {
let had_value = card.values.remove(field).is_some();
let had_choice = card.visibility.remove(field).is_some();
if had_value || had_choice {
ks.insert(card_key(&card.entry_id), &card).await?;
purged += usize::from(had_value);
}
}
Ok(purged)
}
pub fn is_derived(key: &str) -> bool {
DERIVED_KEYS.contains(&key)
}
pub fn required_complete(card: &Card, schema: &[FieldDef]) -> bool {
missing_required(card, schema).is_empty()
}
pub fn missing_required(card: &Card, schema: &[FieldDef]) -> Vec<String> {
schema
.iter()
.filter(|f| {
f.required
&& f.state == FieldState::Active
&& f.source != FieldSource::Derived
&& !card.values.contains_key(&f.key)
})
.map(|f| f.key.clone())
.collect()
}
pub fn listing_of(card: &Card) -> Listing {
card.discoverability.listing
}
#[cfg(test)]
mod tests {
use super::*;
use crate::directory::fields::base_fields;
use serde_json::json;
fn card() -> Card {
fresh("did:example:carol", &Settings::default(), None, Utc::now())
}
fn update(p: Value) -> MemberUpdate {
MemberUpdate::from_payload(&p).unwrap()
}
fn apply(c: &Card, p: Value, at: DateTime<Utc>) -> Result<Applied, CardError> {
apply_member_update(
c,
&base_fields(),
&update(p),
Writer::Member,
"urn:uuid:1",
at,
at,
)
}
#[test]
fn vti_dir_050_entry_ids_are_random_and_well_formed() {
let a = new_entry_id();
let b = new_entry_id();
assert_ne!(a, b);
let re = regress::Regex::new("^dir_[A-Za-z0-9_-]{16,64}$").unwrap();
assert!(re.find(&a).is_some(), "{a}");
}
#[test]
fn a_fresh_card_starts_narrow() {
let c = fresh(
"did:example:x",
&Settings::default(),
Some("Carol"),
Utc::now(),
);
assert_eq!(c.values.get("displayName"), Some(&json!("Carol")));
assert!(c.visibility.is_empty());
assert_eq!(c.discoverability.listing, Listing::Listed);
assert!(!c.discoverability.external);
assert_eq!(c.revision, 0);
}
#[test]
fn vti_dir_012_an_older_update_is_superseded() {
let c = card();
let t1 = Utc::now();
let a = apply(&c, json!({ "values": { "displayName": "Carol" } }), t1).unwrap();
let earlier = t1 - chrono::Duration::seconds(5);
assert_eq!(
apply(
&a.card,
json!({ "values": { "displayName": "Old" } }),
earlier
)
.unwrap_err(),
CardError::Superseded { revision: 1 }
);
assert_eq!(
apply(&a.card, json!({ "expectedRevision": 0 }), t1).unwrap_err(),
CardError::Superseded { revision: 1 }
);
}
#[test]
fn vti_dir_013_a_member_writes_only_member_fields() {
let c = card();
let now = Utc::now();
assert_eq!(
apply(&c, json!({ "values": { "role": "admin" } }), now).unwrap_err(),
CardError::FieldNotWritable("role".into())
);
assert_eq!(
apply(&c, json!({ "values": { "org.example.x": "y" } }), now).unwrap_err(),
CardError::FieldUnknown("org.example.x".into())
);
assert_eq!(
apply(&c, json!({ "visibility": { "role": "public" } }), now).unwrap_err(),
CardError::FieldNotWritable("role".into())
);
apply(&c, json!({ "visibility": { "did": "members" } }), now).unwrap();
}
#[test]
fn a_visibility_above_the_ceiling_is_refused_not_clamped() {
let c = card();
assert_eq!(
apply(&c, json!({ "visibility": { "did": "public" } }), Utc::now()).unwrap_err(),
CardError::AboveCeiling {
field: "did".into(),
ceiling: Audience::Members
}
);
}
#[test]
fn external_publication_is_not_available() {
let c = card();
let r = apply(
&c,
json!({
"values": { "displayName": "Carol" },
"discoverability": { "listing": "listed", "search": "byName", "external": true }
}),
Utc::now(),
);
assert_eq!(r.unwrap_err(), CardError::NotAvailable);
}
#[test]
fn a_delegated_key_cannot_make_anything_public() {
let c = card();
let r = apply_member_update(
&c,
&base_fields(),
&update(json!({ "visibility": { "displayName": "public" } })),
Writer::Delegated,
"urn:uuid:2",
Utc::now(),
Utc::now(),
);
assert_eq!(r.unwrap_err(), CardError::NeedsMembershipDid);
}
#[test]
fn narrowing_is_told_apart_from_widening() {
let c = card();
let now = Utc::now();
let narrow = apply(
&c,
json!({
"visibility": { "displayName": "admins" },
"discoverability": { "listing": "hidden", "search": "off", "external": false },
"introductions": "off",
}),
now,
)
.unwrap();
assert!(!narrow.widens);
let wide = apply(
&narrow.card,
json!({ "visibility": { "displayName": "members" } }),
now,
)
.unwrap();
assert!(wide.widens);
let value = apply(&c, json!({ "values": { "pronouns": "she/her" } }), now).unwrap();
assert!(value.widens);
let cleared = apply(&value.card, json!({ "values": { "pronouns": null } }), now).unwrap();
assert!(!cleared.widens);
assert!(!cleared.card.values.contains_key("pronouns"));
}
#[test]
fn group_audience_null_and_absent_differ() {
let u = update(json!({ "groupAudience": null }));
assert_eq!(u.group_audience, Some(None));
let u = update(json!({}));
assert_eq!(u.group_audience, None);
}
#[test]
fn an_assertion_writes_only_admin_fields() {
let c = card();
let mut values = BTreeMap::new();
values.insert("displayName".to_string(), Some(json!("X")));
assert_eq!(
apply_assertion(&c, &base_fields(), &values, Utc::now()).unwrap_err(),
CardError::FieldNotWritable("displayName".into())
);
}
#[test]
fn vti_dir_016_a_card_is_bounded() {
let mut c = card();
for i in 0..10 {
c.values.insert(format!("k{i}"), json!("x".repeat(2000)));
}
assert!(check_size(&c).is_err());
}
#[tokio::test]
async fn vti_dir_122_rotation_keeps_the_entry_id() {
let ks = super::super::tests::scratch().await;
let s = Settings::default();
let c = ensure(&ks, "did:example:old", &s, None).await.unwrap();
rotate(&ks, "did:example:old", "did:example:new")
.await
.unwrap();
assert!(by_did(&ks, "did:example:old").await.unwrap().is_none());
let moved = by_did(&ks, "did:example:new").await.unwrap().unwrap();
assert_eq!(moved.entry_id, c.entry_id);
assert_eq!(moved.did, "did:example:new");
}
#[tokio::test]
async fn vti_dir_121_a_departure_erases_or_keeps_the_card() {
let ks = super::super::tests::scratch().await;
let s = Settings::default();
ensure(&ks, "did:example:a", &s, None).await.unwrap();
ensure(&ks, "did:example:b", &s, None).await.unwrap();
on_departure(&ks, "did:example:a", Departure::Erase)
.await
.unwrap();
on_departure(&ks, "did:example:b", Departure::Keep)
.await
.unwrap();
assert!(by_did(&ks, "did:example:a").await.unwrap().is_none());
assert!(by_did(&ks, "did:example:b").await.unwrap().is_some());
assert_eq!(all(&ks).await.unwrap().len(), 1);
}
#[tokio::test]
async fn a_retired_field_is_purged_from_every_card() {
let ks = super::super::tests::scratch().await;
let s = Settings::default();
let mut c = ensure(&ks, "did:example:a", &s, None).await.unwrap();
c.values.insert("org.example.gone".into(), json!("v"));
c.visibility
.insert("org.example.gone".into(), Audience::Members);
ks.insert(card_key(&c.entry_id), &c).await.unwrap();
assert_eq!(purge_field(&ks, "org.example.gone").await.unwrap(), 1);
let c = by_did(&ks, "did:example:a").await.unwrap().unwrap();
assert!(c.values.is_empty() && c.visibility.is_empty());
}
}