techne-sdk 0.1.0

TechneSDK's logic written once: version ranking (eventState) and offline record verification over the published check, shipped to TypeScript as WebAssembly; with opt-in atproto plumbing (service auth, DID resolution, signed labels, proven record fetches, PDS password sessions) behind the `crypto` and `net` features.
Documentation
//! What an app can check for itself about a record it read (P13.5): the pure check from
//! `techne-lexicon-check`, over records the app holds, with nothing asked of the platform.
//!
//! [`verify`] answers three things about one record:
//! - **valid**: its body hashes to the CID it was served with (a tampered body does not), and
//!   [`decide`] calls the version valid (lexicon, placement, provenance) over the claims the app holds;
//! - **signer**: the repo it is from (the DID in its AT-URI). atproto signs a repo's commits, not each
//!   record, so this is whose repo the record claims to be in; proving the commit signature needs the
//!   commit and its MST path, which a read does not carry;
//! - **chainVerified**: the commons root vouches for the signer's `publisher/self` declaration and the
//!   declaration covers the record's collection ([`chain::vouched`], [`chain::writer`]), checked
//!   offline: the credential pins the declaration by CID, and every record used is first checked
//!   against its own CID (which proves a body matches the CID it came with, not which repo served it:
//!   the credential's own place is its AT-URI in the root's repo, as `chain::vouched` requires). False
//!   whenever the record itself is not intact.
//!
//! The caller holds one declaration per account, its current one: an edited declaration is untrusted
//! until the root re-issues, and an app that kept the old one beside an unrevoked credential would
//! still count it. Roles and admissions are as of the version's first-seen time, as the caller asserts.
//!
//! [`Claims`] is the data form of `techne_lexicon_check::Claims`: the root, the records the app has read
//! (credentials, declarations, revocations), and any roles and admissions it knows.

use techne_lexicon_check::chain;
use techne_lexicon_check::{parse_at_uri, same_uri};
use techne_lexicon_check::{dag_cbor, decide, Decision, Endorsements, Role, Vouch};
use serde::{Deserialize, Serialize};
use serde_json::Value;

/// One record as read: its address, the CID it was served with, and its body.
#[derive(Debug, Clone, Deserialize)]
pub struct Record {
    pub uri: String,
    pub cid: String,
    pub value: Value,
}

impl Record {
    fn as_chain(&self) -> chain::Record<'_> {
        chain::Record { uri: &self.uri, cid: &self.cid, body: &self.value }
    }

    /// The body hashes to `cid`.
    pub fn intact(&self) -> bool {
        dag_cbor::cid_for_json(&self.value).is_ok_and(|c| c.to_string() == self.cid)
    }
}

/// A member's role in a collective, as the app knows it.
#[derive(Debug, Clone, Deserialize)]
pub struct KnownRole {
    pub member: String,
    pub collective: String,
    /// The role held; `None`: a membership is known but none covered the time.
    pub role: Option<i16>,
}

/// Whether an author passed a space's write policy, as the app knows it.
#[derive(Debug, Clone, Deserialize)]
pub struct KnownAdmission {
    pub author: String,
    pub space: String,
    pub admitted: bool,
}

/// What the app holds to check records against.
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
pub struct Claims {
    /// The commons root DID.
    pub root: Option<String>,
    /// Records the app has read: the root's credentials and revocations, accounts' declarations.
    /// A record whose body does not hash to its CID is ignored.
    pub records: Vec<Record>,
    pub roles: Vec<KnownRole>,
    pub admissions: Vec<KnownAdmission>,
}

impl Claims {
    fn intact(&self) -> impl Iterator<Item = &Record> {
        self.records.iter().filter(|r| r.intact())
    }

    /// The root's credential over `did`'s declaration, both held and intact: the pair that vouches now
    /// if there is one, else the first pair that names it (so `decide` can say why it does not).
    fn held_vouch(&self, did: &str) -> Option<Vouch<'_>> {
        // A revocation counts wherever it is read: its presence is what revokes. A credential keeps the
        // spelling it was issued under, so its `revocation` and `record` match a record read under any
        // spelling of the collection (`same_uri`: same repo and rkey, a renamed collection's old or new name).
        let revoked = |cred: &Record| {
            cred.value.get("revocation").and_then(Value::as_str).is_some_and(|at| self.records.iter().any(|r| same_uri(&r.uri, at)))
        };
        let root = self.root.as_deref().unwrap_or_default();
        let pairs = self
            .intact()
            .filter(|decl| parse_at_uri(&decl.uri).is_some_and(|(repo, _, _)| repo == did))
            .flat_map(|decl| {
                self.intact()
                    .filter(|cred| cred.value.get("record").and_then(Value::as_str).is_some_and(|r| same_uri(r, &decl.uri)))
                    .map(move |cred| Vouch { declaration: decl.as_chain(), credential: cred.as_chain(), revocation_present: revoked(cred) })
            });
        let pairs: Vec<Vouch<'_>> = pairs.collect();
        let vouches = |v: &Vouch<'_>| chain::vouched(root, v.credential, v.declaration, v.revocation_present);
        pairs.iter().find(|v| vouches(v)).or(pairs.first()).copied()
    }

    /// `did`'s `publisher/self` declaration, when the root vouches for it now.
    fn vouched_declaration(&self, did: &str) -> Option<&Value> {
        let root = self.root.as_deref()?;
        self.held_vouch(did)
            .filter(|v| chain::vouched(root, v.credential, v.declaration, v.revocation_present))
            .map(|v| v.declaration.body)
    }
}

impl Endorsements for Claims {
    fn root(&self) -> Option<&str> {
        self.root.as_deref()
    }
    fn endorses(&self, did: &str, collection: &str) -> bool {
        self.vouched_declaration(did).is_some_and(|decl| chain::writer(decl, collection))
    }
}

impl techne_lexicon_check::Claims for Claims {
    fn role_at(&self, member: &str, collective: &str, _at: &str) -> Role {
        match self.roles.iter().find(|r| r.member == member && r.collective == collective) {
            Some(KnownRole { role: Some(n), .. }) => Role::Held(*n),
            Some(_) => Role::NotHeld,
            None => Role::Unknown,
        }
    }
    fn admitted(&self, author: &str, space: &str, _at: &str) -> Option<bool> {
        self.admissions.iter().find(|a| a.author == author && a.space == space).map(|a| a.admitted)
    }
    fn vouch(&self, author: &str) -> Option<Vouch<'_>> {
        self.held_vouch(author)
    }
}

/// What an app shows for one record.
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Verification {
    pub valid: bool,
    pub signer: String,
    pub chain_verified: bool,
    /// Why it is not valid (empty when it is).
    pub problems: Vec<String>,
}

/// Verify one record against what the app holds. See the module docs.
pub fn verify(record: &Record, claims: &Claims) -> Verification {
    let Some((signer, collection, rkey)) = parse_at_uri(&record.uri) else {
        return Verification { valid: false, signer: String::new(), chain_verified: false, problems: vec!["uri: not an AT-URI".into()] };
    };
    let intact = record.intact();
    let mut problems = vec![];
    if !intact {
        problems.push(format!("cid: the body does not hash to {}", record.cid));
    }
    let version = techne_lexicon_check::Version { collection, author: signer, rkey, body: &record.value, space: None };
    match decide(&version, "", claims) {
        Decision::Valid => {}
        Decision::Pending => problems.push("pending: a claim it needs is not known".into()),
        Decision::Invalid { reason } => problems.push(reason),
    }
    Verification {
        valid: problems.is_empty(),
        signer: signer.to_string(),
        // A tampered body says nothing about its signer's chain either: fail closed.
        chain_verified: intact && claims.endorses(signer, collection),
        problems,
    }
}