aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorbryan newbold <bnewbold@robocracy.org>2023-02-19 15:05:50 -0800
committerbryan newbold <bnewbold@robocracy.org>2023-02-19 15:05:50 -0800
commitdeb836b3c34c3d6ee9e2620f1a798b3d0b7ede8b (patch)
tree379a5668e203329ce733eb1b00acec0d77b746bb
parenteff4692a082ea96a0cf48d82fbd16658cbf52a72 (diff)
downloadadenosine-deb836b3c34c3d6ee9e2620f1a798b3d0b7ede8b.tar.gz
adenosine-deb836b3c34c3d6ee9e2620f1a798b3d0b7ede8b.zip
create 'adenosine' crate with library code
-rw-r--r--adenosine/Cargo.toml31
-rw-r--r--adenosine/README.md10
-rw-r--r--adenosine/src/identifiers.rs418
-rw-r--r--adenosine/src/lib.rs248
4 files changed, 707 insertions, 0 deletions
diff --git a/adenosine/Cargo.toml b/adenosine/Cargo.toml
new file mode 100644
index 0000000..a05aa78
--- /dev/null
+++ b/adenosine/Cargo.toml
@@ -0,0 +1,31 @@
+[package]
+name = "adenosine"
+description = "Common libraries for AT protocol and bsky.app"
+keywords = ["atproto"]
+categories = []
+
+# common across workspace
+version = "0.2.0"
+edition = "2021"
+rust-version = "1.61"
+authors = ["Bryan Newbold <bnewbold@robocracy.org>"]
+license = "AGPL-3.0-or-later"
+readme = "README.md"
+repository = "https://gitlab.com/bnewbold/adenosine"
+
+[dependencies]
+# NOTE: could try 'rustls-tls' feature instead of default native TLS?
+reqwest = { version = "0.11", features = ["blocking", "json"] }
+serde = { version = "1", features = ["serde_derive"] }
+serde_json = "1"
+base64 = "0.13"
+regex = "1"
+lazy_static = "1"
+data-encoding = "2"
+rand = "0.8"
+time = { version = "0.3", features = ["formatting"] }
+
+# uncertain about these...
+anyhow = "1"
+log = "0.4"
+env_logger = "0.7"
diff --git a/adenosine/README.md b/adenosine/README.md
new file mode 100644
index 0000000..88f630d
--- /dev/null
+++ b/adenosine/README.md
@@ -0,0 +1,10 @@
+
+`adenosine`: library code for AT protocol (atproto.com)
+==================================================================
+
+This is a simple, enthusiast-grade library for the work-in-progress AT Protocol
+([atproto.com](https://atproto.com)). It is used by both `adenosine-cli` and
+`adenosine-pds`.
+
+NOTE: this library code is currently AGPL, but will probably switch to
+something permission (eg, MIT), with the applications staying AGPL.
diff --git a/adenosine/src/identifiers.rs b/adenosine/src/identifiers.rs
new file mode 100644
index 0000000..7e93d71
--- /dev/null
+++ b/adenosine/src/identifiers.rs
@@ -0,0 +1,418 @@
+use anyhow::{anyhow, Result};
+use lazy_static::lazy_static;
+use regex::Regex;
+use std::fmt;
+use std::ops::Deref;
+use std::str::FromStr;
+use std::time::SystemTime;
+
+#[derive(Debug, PartialEq, Eq, Clone)]
+pub enum DidOrHost {
+ Did(String, String),
+ Host(String),
+}
+
+impl FromStr for DidOrHost {
+ type Err = anyhow::Error;
+
+ /// DID syntax is specified in: <https://w3c.github.io/did-core/#did-syntax>
+ ///
+ /// Lazy partial hostname regex, isn't very correct.
+ fn from_str(s: &str) -> Result<Self, Self::Err> {
+ lazy_static! {
+ static ref DID_RE: Regex =
+ Regex::new(r"^did:([a-z]{1,64}):([a-zA-Z0-9\-.]{1,1024})$").unwrap();
+ }
+ lazy_static! {
+ static ref HOSTNAME_RE: Regex =
+ Regex::new(r"^[A-Za-z][A-Za-z0-9-]*(\.[A-Za-z][A-Za-z0-9-]*)+$").unwrap();
+ }
+ if let Some(caps) = DID_RE.captures(s) {
+ Ok(Self::Did(caps[1].to_string(), caps[2].to_string()))
+ } else if HOSTNAME_RE.is_match(s) {
+ Ok(Self::Host(s.to_string()))
+ } else {
+ Err(anyhow!("does not match as a DID or hostname: {}", s))
+ }
+ }
+}
+
+impl fmt::Display for DidOrHost {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::Host(v) => write!(f, "{}", v),
+ Self::Did(m, v) => write!(f, "did:{}:{}", m, v),
+ }
+ }
+}
+
+#[test]
+fn test_didorhost() {
+ assert_eq!(
+ DidOrHost::from_str("hyde.test").unwrap(),
+ DidOrHost::Host("hyde.test".to_string())
+ );
+ assert_eq!(
+ DidOrHost::from_str("did:method:blah").unwrap(),
+ DidOrHost::Did("method".to_string(), "blah".to_string())
+ );
+
+ assert!(DidOrHost::from_str("barestring").is_err());
+ assert!(DidOrHost::from_str("did:partial:").is_err());
+ assert!(DidOrHost::from_str("").is_err());
+ assert!(DidOrHost::from_str(" ").is_err());
+ assert!(DidOrHost::from_str("1234").is_err());
+
+ assert!(DidOrHost::from_str("multi.part.domain").is_ok());
+ assert!(DidOrHost::from_str("did:is:weird").is_ok());
+ assert!(DidOrHost::from_str("did:plc:bv6ggog3tya2z3vxsub7hnal").is_ok());
+}
+
+#[derive(Debug, PartialEq, Eq, Clone)]
+pub struct AtUri {
+ pub repository: DidOrHost,
+ pub collection: Option<String>,
+ pub record: Option<String>,
+ pub fragment: Option<String>,
+}
+
+impl FromStr for AtUri {
+ type Err = anyhow::Error;
+
+ fn from_str(s: &str) -> Result<Self, Self::Err> {
+ lazy_static! {
+ static ref ATURI_RE: Regex = Regex::new(r"^at://([a-zA-Z0-9:_\.-]+)(/(([a-zA-Z0-9\.]+))?)?(/(([a-zA-Z0-9\.-]+))?)?(#([a-zA-Z0-9/-]+))?$").unwrap();
+ }
+ if let Some(caps) = ATURI_RE.captures(s) {
+ let uri = AtUri {
+ repository: DidOrHost::from_str(&caps[1])?,
+ collection: caps.get(4).map(|v| v.as_str().to_string()),
+ record: caps.get(7).map(|v| v.as_str().to_string()),
+ fragment: caps.get(9).map(|v| v.as_str().to_string()),
+ };
+ Ok(uri)
+ } else {
+ Err(anyhow!("couldn't parse as an at:// URI: {}", s))
+ }
+ }
+}
+
+impl fmt::Display for AtUri {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ write!(f, "at://{}", self.repository)?;
+ if let Some(ref c) = self.collection {
+ write!(f, "/{}", c)?;
+ };
+ if let Some(ref r) = self.record {
+ write!(f, "/{}", r)?;
+ };
+ if let Some(ref v) = self.fragment {
+ write!(f, "#{}", v)?;
+ };
+ Ok(())
+ }
+}
+
+#[test]
+fn test_aturi() {
+ assert!(AtUri::from_str("at://bob.com").is_ok());
+ assert!(AtUri::from_str("at://bob.com/").is_ok());
+ assert!(AtUri::from_str("at://did:plc:bv6ggog3tya2z3vxsub7hnal").is_ok());
+ assert!(AtUri::from_str("at://bob.com/io.example.song").is_ok());
+ assert!(AtUri::from_str("at://bob.com/io.example.song/").is_ok());
+ assert!(AtUri::from_str("at://bob.com/io.example.song/3yI5-c1z-cc2p-1a").is_ok());
+ assert!(AtUri::from_str("at://bob.com/io.example.song/3yI5-c1z-cc2p-1a#/title").is_ok());
+ assert!(
+ AtUri::from_str("at://did:plc:ltk4reuh7rkoy2frnueetpb5/app.bsky.follow/3jg23pbmlhc2a")
+ .is_ok()
+ );
+
+ let uri = AtUri {
+ repository: DidOrHost::Did("some".to_string(), "thing".to_string()),
+ collection: Some("com.atproto.record".to_string()),
+ record: Some("asdf-123".to_string()),
+ fragment: Some("/path".to_string()),
+ };
+ assert_eq!(
+ "at://did:some:thing/com.atproto.record/asdf-123#/path",
+ uri.to_string()
+ );
+ println!("{:?}", AtUri::from_str(&uri.to_string()));
+ assert!(AtUri::from_str(&uri.to_string()).is_ok());
+
+ let uri = AtUri::from_str("at://bob.com/io.example.song/3yI5-c1z-cc2p-1a#/title").unwrap();
+ assert_eq!(uri.repository, DidOrHost::Host("bob.com".to_string()));
+ assert_eq!(uri.collection, Some("io.example.song".to_string()));
+ assert_eq!(uri.record, Some("3yI5-c1z-cc2p-1a".to_string()));
+ assert_eq!(uri.fragment, Some("/title".to_string()));
+
+ let uri = AtUri::from_str("at://bob.com/io.example.song/").unwrap();
+ assert_eq!(uri.repository, DidOrHost::Host("bob.com".to_string()));
+ assert_eq!(uri.collection, Some("io.example.song".to_string()));
+}
+
+/// A String (newtype) representing an NSID
+#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, serde::Serialize)]
+pub struct Nsid(String);
+
+impl FromStr for Nsid {
+ type Err = anyhow::Error;
+
+ fn from_str(s: &str) -> Result<Self, Self::Err> {
+ lazy_static! {
+ static ref NSID_RE: Regex = Regex::new(r"^([a-z][a-z0-9-]+\.)+[a-zA-Z0-9-]+$").unwrap();
+ }
+ if NSID_RE.is_match(s) {
+ Ok(Self(s.to_string()))
+ } else {
+ Err(anyhow!("does not match as an NSID: {}", s))
+ }
+ }
+}
+
+impl Deref for Nsid {
+ type Target = str;
+
+ fn deref(&self) -> &Self::Target {
+ &self.0
+ }
+}
+
+impl Nsid {
+ pub fn domain(&self) -> String {
+ self.rsplit('.').skip(1).collect::<Vec<&str>>().join(".")
+ }
+
+ pub fn name(&self) -> String {
+ self.split('.')
+ .last()
+ .expect("multiple segments in NSID")
+ .to_string()
+ }
+}
+
+impl fmt::Display for Nsid {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ write!(f, "{}", self.0)
+ }
+}
+
+#[test]
+fn test_nsid() {
+ assert!(Nsid::from_str("com.atproto.recordType").is_ok());
+
+ let nsid = Nsid::from_str("com.atproto.recordType").unwrap();
+ assert_eq!(nsid.domain(), "atproto.com".to_string());
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, serde::Serialize)]
+pub struct Did(String);
+
+impl FromStr for Did {
+ type Err = anyhow::Error;
+
+ /// DID syntax is specified in: <https://w3c.github.io/did-core/#did-syntax>
+ ///
+ /// This regex does not follow that definition exactly.
+ fn from_str(s: &str) -> Result<Self, Self::Err> {
+ lazy_static! {
+ static ref DID_RE: Regex =
+ Regex::new(r"^did:([a-z]{1,32}):([a-zA-Z0-9\-.]{1,256})$").unwrap();
+ }
+ if DID_RE.is_match(s) {
+ Ok(Self(s.to_string()))
+ } else {
+ Err(anyhow!("does not match as a DID: {}", s))
+ }
+ }
+}
+
+impl Deref for Did {
+ type Target = str;
+
+ fn deref(&self) -> &Self::Target {
+ &self.0
+ }
+}
+
+impl Did {
+ pub fn did_type(&self) -> String {
+ self.split(':').nth(1).unwrap().to_string()
+ }
+}
+
+impl fmt::Display for Did {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ write!(f, "{}", self.0)
+ }
+}
+
+#[test]
+fn test_did() {
+ assert!(Did::from_str("did:web:asdf.org").is_ok());
+ assert!(Did::from_str("did:plc:asdf").is_ok());
+
+ assert!(Did::from_str("bob.com").is_err());
+ assert!(Did::from_str("").is_err());
+ assert!(Did::from_str("did:").is_err());
+ assert!(Did::from_str("did:plc:").is_err());
+ assert!(Did::from_str("plc:asdf").is_err());
+ assert!(Did::from_str("DID:thing:thang").is_err());
+
+ assert_eq!(
+ Did::from_str("did:web:asdf.org").unwrap().did_type(),
+ "web".to_string()
+ );
+}
+
+lazy_static! {
+ /// Sortable base32 encoding, as bluesky implements/defines
+ static ref BASE32SORT: data_encoding::Encoding = {
+ let mut spec = data_encoding::Specification::new();
+ spec.symbols.push_str("234567abcdefghijklmnopqrstuvwxyz");
+ spec.padding = None;
+ spec.encoding().unwrap()
+ };
+}
+
+/// A string identifier for individual records, based on UNIX timestamp in microseconds.
+///
+/// See also: https://github.com/bluesky-social/atproto/issues/334
+///
+/// Pretty permissive about what can be parsed/accepted, because there were some old TIDs floating
+/// around with weird format.
+#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
+pub struct Tid(String);
+
+impl FromStr for Tid {
+ type Err = anyhow::Error;
+
+ fn from_str(s: &str) -> Result<Self, Self::Err> {
+ lazy_static! {
+ static ref TID_RE: Regex = Regex::new(r"^[0-9a-zA-Z-]{13,20}$").unwrap();
+ }
+ if TID_RE.is_match(s) {
+ Ok(Self(s.to_string()))
+ } else {
+ Err(anyhow!("does not match as a TID: {}", s))
+ }
+ }
+}
+
+impl Deref for Tid {
+ type Target = str;
+
+ fn deref(&self) -> &Self::Target {
+ &self.0
+ }
+}
+
+impl fmt::Display for Tid {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ write!(f, "{}", self.0)
+ }
+}
+
+impl Tid {
+ pub fn new(micros: u64, clock_id: u16) -> Self {
+ // 53 bits of millis
+ let micros = micros & 0x001FFFFFFFFFFFFF;
+ // 10 bits of clock ID
+ let clock_id = clock_id & 0x03FF;
+ let val: u64 = (micros << 10) | (clock_id as u64);
+ // big-endian encoding
+ let enc = BASE32SORT.encode(&val.to_be_bytes());
+ Tid(format!(
+ "{}-{}-{}-{}",
+ &enc[0..4],
+ &enc[4..7],
+ &enc[7..11],
+ &enc[11..13]
+ ))
+ }
+}
+
+#[test]
+fn test_tid() {
+ Tid::from_str("3yI5-c1z-cc2p-1a").unwrap();
+ assert!(Tid::from_str("3jg6anbimrc2a").is_ok());
+ assert!(Tid::from_str("3yI5-c1z-cc2p-1a").is_ok());
+
+ Tid::from_str("asdf234as4asdf234").unwrap();
+ assert!(Tid::from_str("asdf234as4asdf234").is_ok());
+
+ assert!(Tid::from_str("").is_err());
+ assert!(Tid::from_str("com").is_err());
+ assert!(Tid::from_str("com.blah.Thing").is_err());
+ assert!(Tid::from_str("did:stuff:blah").is_err());
+
+ let t1 = Tid::new(0, 0);
+ assert_eq!(t1.to_string(), "2222-222-2222-22".to_string());
+}
+
+/// TID Generator
+///
+/// This version uses 53-bit microsecond counter (since UNIX epoch), and a random 10-bit clock id.
+///
+/// If the current timestamp is not greater than the last timestamp (either because clock did not
+/// advance monotonically, or multiple TIDs were generated in the same microsecond (very unlikely),
+/// the timestamp is simply incremented.
+pub struct Ticker {
+ last_timestamp: u64,
+ clock_id: u16,
+}
+
+impl Ticker {
+ pub fn new() -> Self {
+ let mut ticker = Self {
+ last_timestamp: 0,
+ // mask to 10 bits
+ clock_id: rand::random::<u16>() & 0x03FF,
+ };
+ // prime the pump
+ ticker.next_tid();
+ ticker
+ }
+
+ pub fn next_tid(&mut self) -> Tid {
+ let now = SystemTime::now()
+ .duration_since(SystemTime::UNIX_EPOCH)
+ .expect("timestamp in micros since UNIX epoch")
+ .as_micros() as u64;
+ // mask to 53 bits
+ let now = now & 0x001FFFFFFFFFFFFF;
+ if now > self.last_timestamp {
+ self.last_timestamp = now;
+ } else {
+ self.last_timestamp += 1;
+ }
+ Tid::new(self.last_timestamp, self.clock_id)
+ }
+}
+
+impl Default for Ticker {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+#[test]
+fn test_ticker() {
+ let mut ticker = Ticker::new();
+ let mut prev = ticker.next_tid();
+ let mut next = ticker.next_tid();
+ for _ in [0..100] {
+ println!("{} >? {}", next, prev);
+ assert!(next > prev);
+ prev = next;
+ next = ticker.next_tid();
+ }
+ println!("{}", prev);
+ assert_eq!(prev, Tid::from_str(&prev.to_string()).unwrap());
+ assert_eq!(next[13..16], prev[13..16]);
+
+ let mut other_ticker = Ticker::new();
+ let other = other_ticker.next_tid();
+ assert!(other > next);
+ assert!(next[13..16] != other[13..16]);
+}
diff --git a/adenosine/src/lib.rs b/adenosine/src/lib.rs
new file mode 100644
index 0000000..461930f
--- /dev/null
+++ b/adenosine/src/lib.rs
@@ -0,0 +1,248 @@
+use anyhow::anyhow;
+pub use anyhow::Result;
+use reqwest::header;
+use serde_json::Value;
+use std::collections::HashMap;
+use std::str::FromStr;
+use std::time::Duration;
+
+pub mod identifiers;
+use identifiers::Nsid;
+
+static APP_USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"),);
+
+#[derive(Debug, PartialEq, Eq, Clone)]
+pub enum XrpcMethod {
+ Get,
+ Post,
+}
+
+impl FromStr for XrpcMethod {
+ type Err = anyhow::Error;
+
+ fn from_str(s: &str) -> Result<Self, Self::Err> {
+ match s {
+ "get" => Ok(XrpcMethod::Get),
+ "post" => Ok(XrpcMethod::Post),
+ _ => Err(anyhow!("unknown method: {}", s)),
+ }
+ }
+}
+
+pub struct XrpcClient {
+ http_client: reqwest::blocking::Client,
+ host: String,
+}
+
+impl XrpcClient {
+ pub fn new(host: String, auth_token: Option<String>) -> Result<Self> {
+ let mut headers = header::HeaderMap::new();
+ if let Some(token) = &auth_token {
+ let mut auth_value = header::HeaderValue::from_str(&format!("Bearer {}", token))?;
+ auth_value.set_sensitive(true);
+ headers.insert(header::AUTHORIZATION, auth_value);
+ };
+
+ let http_client = reqwest::blocking::Client::builder()
+ .default_headers(headers)
+ .user_agent(APP_USER_AGENT)
+ .timeout(Duration::from_secs(30))
+ //.danger_accept_invalid_certs(true)
+ .build()
+ .expect("ERROR :: Could not build reqwest client");
+
+ Ok(XrpcClient { http_client, host })
+ }
+
+ pub fn get(
+ &self,
+ nsid: &Nsid,
+ params: Option<HashMap<String, String>>,
+ ) -> Result<Option<Value>> {
+ log::debug!("XRPC GET endpoint={} params={:?}", nsid, params);
+ let params: HashMap<String, String> = params.unwrap_or_default();
+ let res = self
+ .http_client
+ .get(format!("{}/xrpc/{}", self.host, nsid))
+ .query(&params)
+ .send()?;
+ // TODO: refactor this error handling stuff into single method
+ if res.status() == 400 {
+ let val: Value = res.json()?;
+ return Err(anyhow!(
+ "XRPC Bad Request (400): {}",
+ val["message"].as_str().unwrap_or("unknown")
+ ));
+ } else if res.status() == 500 {
+ let val: Value = res.json()?;
+ return Err(anyhow!(
+ "XRPC Internal Error (500): {}",
+ val["message"].as_str().unwrap_or("unknown")
+ ));
+ }
+ let res = res.error_for_status()?;
+ Ok(res.json()?)
+ }
+
+ pub fn get_to_writer<W: std::io::Write>(
+ &self,
+ nsid: &Nsid,
+ params: Option<HashMap<String, String>>,
+ output: &mut W,
+ ) -> Result<u64> {
+ let params: HashMap<String, String> = params.unwrap_or_default();
+ let res = self
+ .http_client
+ .get(format!("{}/xrpc/{}", self.host, nsid))
+ .query(&params)
+ .send()?;
+ if res.status() == 400 {
+ let val: Value = res.json()?;
+ return Err(anyhow!(
+ "XRPC Bad Request (400): {}",
+ val["message"].as_str().unwrap_or("unknown")
+ ));
+ } else if res.status() == 500 {
+ let val: Value = res.json()?;
+ return Err(anyhow!(
+ "XRPC Internal Error (500): {}",
+ val["message"].as_str().unwrap_or("unknown")
+ ));
+ }
+ let mut res = res.error_for_status()?;
+ Ok(res.copy_to(output)?)
+ }
+
+ pub fn post(
+ &self,
+ nsid: &Nsid,
+ params: Option<HashMap<String, String>>,
+ body: Option<Value>,
+ ) -> Result<Option<Value>> {
+ let params: HashMap<String, String> = params.unwrap_or_default();
+ log::debug!(
+ "XRPC POST endpoint={} params={:?} body={:?}",
+ nsid,
+ params,
+ body
+ );
+ let mut req = self
+ .http_client
+ .post(format!("{}/xrpc/{}", self.host, nsid))
+ .query(&params);
+ req = if let Some(b) = body {
+ req.json(&b)
+ } else {
+ req
+ };
+ let res = req.send()?;
+ if res.status() == 400 {
+ let val: Value = res.json()?;
+ return Err(anyhow!(
+ "XRPC Bad Request (400): {}",
+ val["message"].as_str().unwrap_or("unknown")
+ ));
+ } else if res.status() == 500 {
+ let val: Value = res.json()?;
+ return Err(anyhow!(
+ "XRPC Internal Error (500): {}",
+ val["message"].as_str().unwrap_or("unknown")
+ ));
+ }
+ let res = res.error_for_status()?;
+ if res.content_length() == Some(0) {
+ Ok(None)
+ } else {
+ Ok(res.json()?)
+ }
+ }
+
+ pub fn post_cbor_from_reader<R: std::io::Read>(
+ &self,
+ nsid: &Nsid,
+ params: Option<HashMap<String, String>>,
+ input: &mut R,
+ ) -> Result<Option<Value>> {
+ let params: HashMap<String, String> = params.unwrap_or_default();
+ let mut buf: Vec<u8> = Vec::new();
+ input.read_to_end(&mut buf)?;
+ let res = self
+ .http_client
+ .post(format!("{}/xrpc/{}", self.host, nsid))
+ .query(&params)
+ .header(reqwest::header::CONTENT_TYPE, "application/cbor")
+ .body(buf)
+ .send()?;
+ if res.status() == 400 {
+ let val: Value = res.json()?;
+ return Err(anyhow!(
+ "XRPC Bad Request: {}",
+ val["message"].as_str().unwrap_or("unknown")
+ ));
+ }
+ let res = res.error_for_status()?;
+ Ok(res.json()?)
+ }
+
+ // reqwest::blocking::Body
+}
+
+/// Tries to parse a DID internal identifier from a JWT (as base64-encoded token)
+pub fn parse_did_from_jwt(jwt: &str) -> Result<String> {
+ let second_b64 = jwt.split('.').nth(1).ok_or(anyhow!("couldn't parse JWT"))?;
+ let second_json: Vec<u8> = base64::decode_config(second_b64, base64::URL_SAFE)?;
+ let obj: Value = serde_json::from_slice(&second_json)?;
+ // trying to also support pulling "aud" as DID; not sure this is actually correct use of
+ // UCAN/JWT semantics?
+ let did = obj["sub"]
+ .as_str()
+ .or(obj["aud"].as_str())
+ .ok_or(anyhow!("couldn't find DID subject in JWT"))?
+ .to_string();
+ if !did.starts_with("did:") {
+ return Err(anyhow!("couldn't find DID subject in JWT"));
+ }
+ Ok(did)
+}
+
+#[test]
+fn test_parse_jwt() {
+ assert!(parse_did_from_jwt(".").is_err());
+ // JWT from atproto ("sub")
+ assert_eq!(
+ parse_did_from_jwt("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJkaWQ6cGxjOmV4M3NpNTI3Y2QyYW9nYnZpZGtvb296YyIsImlhdCI6MTY2NjgyOTM5M30.UvZgTqvaJICONa1wIUT1bny7u3hqVAqWhWy3qeuyZrE").unwrap(),
+ "did:plc:ex3si527cd2aogbvidkooozc",
+ );
+ // UCAN from adenosine-pds ("aud")
+ assert_eq!(
+ parse_did_from_jwt("eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsInVjdiI6IjAuOS4wLWNhbmFyeSJ9.eyJhdHQiOltdLCJhdWQiOiJkaWQ6cGxjOnM3b25ieWphN2MzeXJzZ3Zob2xrbHM1YiIsImV4cCI6MTY3NTM4Mzg2NywiZmN0IjpbXSwiaXNzIjoiZGlkOmtleTp6RG5hZWRHVGJkb0Frb1NlOG96a3k1WHAzMjZTVFpUSm50aDlHY2dxaTZQYjNzYjczIiwibm5jIjoiTnZURDhENWZjNXFpalIyMWJ1V2Z1ZE02dzlBM2drSy1ac3RtUW03b21pdyIsInByZiI6W119.QwZkb9R17tNhXnY_roqFYgdiIgUnSC18FYWQb3PcH6BU1R5l4W_T4XdACyczPGfM-jAnF2r2loBXDntYVS6N5A").unwrap(),
+ "did:plc:s7onbyja7c3yrsgvholkls5b",
+ );
+ assert!(parse_did_from_jwt("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9").is_err());
+}
+
+/// Helper to generate the current timestamp as right now, UTC, formatted as an RFC 3339 string.
+///
+/// Currently, bluesky PDS expects millisecond precision, so we use that.
+///
+/// Returns something like "2022-11-22T09:21:15.640Z"
+pub fn created_at_now() -> String {
+ let now = time::OffsetDateTime::now_utc();
+ // remove microsecond precision, but retain millisecond precision
+ let ms = now.millisecond();
+ let now = now.replace_microsecond(0).unwrap();
+ let now = now.replace_millisecond(ms).unwrap();
+ now.format(&time::format_description::well_known::Rfc3339)
+ .unwrap()
+}
+
+#[test]
+fn test_created_at_now() {
+ // eg: 2022-11-22T09:20:44.123Z
+ let ts = created_at_now();
+ println!("{}", ts);
+ assert_eq!(&ts[4..5], "-");
+ assert_eq!(&ts[7..8], "-");
+ assert_eq!(&ts[10..11], "T");
+ assert_eq!(&ts[23..24], "Z");
+}