AT Protocol PDS + AppView + Tauri Desktop Client, 160-char post limit. - PDS (Rust + axum + sqlx) - Auth: createAccount, createSession, refreshSession - Records: createRecord, deleteRecord (race-safe via SELECT FOR UPDATE) - Feed: feed.like.create, feed.repost.create - Sync: getRepo, getBlocks, getLatestCommit, getRecord (with MST proof), listRepos - Identity: resolveHandle - MST: spec-conformant (at-mst crate, 27 tests) - Repo: signed commits, TID counter (monotonic, 4096 wrap safe) - AppView (Rust + axum + sqlx) - Jetstream consumer (WebSocket, exponential backoff, 38k+ events indexed) - REST API: timeline/home (graph-aware), profile, search, post (with thread hydration) - Handle-sync worker (did:plc + did:web) - JSONB embed storage + thread columns (migration 0003) - Like/repost counter cache (migration 0004) - Tauri 2 + Svelte 5 Desktop Client - System tray (Show/Compose/Quit menu) - OS notifications (tauri-plugin-notification) - Auto-update (tauri-plugin-updater, placeholder endpoint) - Window-state (tauri-plugin-window-state) - 160-char compose with live counter - Image/Link embed rendering - LocalStorage-persisted like state - Timeline with poll (prepend new posts) - Custom TitleBar (transparent, no decorations) - Orange/IBM Plex Mono maarcade design Tests: 231 Rust + 9 vitest = 240 passed.
245 lines
9.2 KiB
Rust
245 lines
9.2 KiB
Rust
use anyhow::Result;
|
|
use async_trait::async_trait;
|
|
use at_crypto::plc_op::PlcOperation;
|
|
use reqwest::Client;
|
|
use serde_json::Value;
|
|
|
|
use crate::handle::DidHandleResolver;
|
|
|
|
#[derive(Clone)]
|
|
pub struct PlcClient {
|
|
pub base_url: String,
|
|
pub client: Client,
|
|
}
|
|
|
|
impl PlcClient {
|
|
pub fn new(base_url: impl Into<String>) -> Self {
|
|
Self {
|
|
base_url: base_url.into(),
|
|
client: Client::builder()
|
|
.timeout(std::time::Duration::from_secs(10))
|
|
.build()
|
|
.unwrap(),
|
|
}
|
|
}
|
|
|
|
pub async fn submit(&self, did: &str, op: &PlcOperation) -> Result<String> {
|
|
let url = format!("{}/{}", self.base_url, did);
|
|
let body = serde_json::to_value(op)?;
|
|
let resp = self.client.post(&url).json(&body).send().await?;
|
|
if !resp.status().is_success() {
|
|
let status = resp.status();
|
|
let text = resp.text().await.unwrap_or_default();
|
|
anyhow::bail!("plc submit failed: {} {}", status, text);
|
|
}
|
|
let v: Value = resp.json().await?;
|
|
Ok(v.get("cid")
|
|
.and_then(|x| x.as_str())
|
|
.unwrap_or_default()
|
|
.to_string())
|
|
}
|
|
|
|
/// Resolve `did:plc:<id>` to its current handle by reading
|
|
/// `<base_url>/<did>/data` and pulling out the `handle` field.
|
|
///
|
|
/// Only `did:plc:` is currently supported; `did:web:` and other
|
|
/// methods return `Ok(None)` (the AppView's handle-sync worker treats
|
|
/// `None` as "skip, try again later", not as an error).
|
|
pub async fn resolve_handle(&self, did: &str) -> Result<Option<String>> {
|
|
DidHandleResolver::resolve_handle(self, did).await
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl DidHandleResolver for PlcClient {
|
|
async fn resolve_handle(&self, did: &str) -> Result<Option<String>> {
|
|
// We only know how to look up PLC DIDs. Anything else (did:web:,
|
|
// did:key:, etc.) is reported as "no handle available" rather
|
|
// than an error.
|
|
let rest = match did.strip_prefix("did:plc:") {
|
|
Some(r) => r,
|
|
None => return Ok(None),
|
|
};
|
|
// Sanity-check the suffix so we don't construct weird URLs.
|
|
if rest.is_empty() || rest.contains('/') {
|
|
return Ok(None);
|
|
}
|
|
|
|
let url = format!("{}/{}/data", self.base_url, did);
|
|
let resp = self.client.get(&url).send().await?;
|
|
let status = resp.status();
|
|
if status.as_u16() == 404 {
|
|
// DID exists syntactically but isn't registered. Not an error.
|
|
return Ok(None);
|
|
}
|
|
if !status.is_success() {
|
|
let text = resp.text().await.unwrap_or_default();
|
|
anyhow::bail!("plc lookup failed: {} {}", status, text);
|
|
}
|
|
let v: Value = resp.json().await?;
|
|
// Modern PLC DID documents don't carry a top-level `handle` field
|
|
// (deprecated in 2024); the handle is encoded as the first
|
|
// `alsoKnownAs` AT URI: `at://<handle>`. We try both, preferring
|
|
// `alsoKnownAs` so we handle current docs, then falling back to
|
|
// the legacy `handle` field for older ones.
|
|
if let Some(aka) = v.get("alsoKnownAs").and_then(|x| x.as_array()) {
|
|
for entry in aka {
|
|
if let Some(s) = entry.as_str() {
|
|
if let Some(handle) = s.strip_prefix("at://") {
|
|
if !handle.is_empty() {
|
|
return Ok(Some(handle.to_string()));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Ok(v.get("handle")
|
|
.and_then(|x| x.as_str())
|
|
.map(str::to_string))
|
|
}
|
|
}
|
|
|
|
pub async fn submit_op(client: &PlcClient, did: &str, op: &PlcOperation) -> Result<String> {
|
|
client.submit(did, op).await
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::time::Duration;
|
|
|
|
/// A 404 from plc.directory (e.g. unknown DID) must come back as
|
|
/// `Ok(None)` — never `Err(_)` — so the worker doesn't log it as a
|
|
/// transient failure every pass.
|
|
#[tokio::test]
|
|
async fn resolve_handle_returns_none_on_404() {
|
|
// Spin up a tiny mock server that always returns 404.
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let addr = listener.local_addr().unwrap();
|
|
let server = tokio::spawn(async move {
|
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
loop {
|
|
let (mut sock, _) = listener.accept().await.unwrap();
|
|
tokio::spawn(async move {
|
|
// Read the request line + headers (don't care about body).
|
|
let mut buf = vec![0u8; 1024];
|
|
let _ = sock.read(&mut buf).await;
|
|
let resp = b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n";
|
|
let _ = sock.write_all(resp).await;
|
|
});
|
|
}
|
|
});
|
|
|
|
let client = PlcClient::new(format!("http://{addr}"));
|
|
let r = tokio::time::timeout(
|
|
Duration::from_secs(2),
|
|
client.resolve_handle("did:plc:nobody"),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
assert!(r.is_none(), "404 must map to Ok(None), got {r:?}");
|
|
server.abort();
|
|
}
|
|
|
|
/// A 2xx response with the expected `handle` field should round-trip.
|
|
#[tokio::test]
|
|
async fn resolve_handle_parses_handle_field() {
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let addr = listener.local_addr().unwrap();
|
|
let server = tokio::spawn(async move {
|
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
loop {
|
|
let (mut sock, _) = listener.accept().await.unwrap();
|
|
tokio::spawn(async move {
|
|
let mut buf = vec![0u8; 1024];
|
|
let _ = sock.read(&mut buf).await;
|
|
let body = br#"{"id":"did:plc:abc","handle":"alice.bsky.social"}"#;
|
|
let resp = format!(
|
|
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n",
|
|
body.len()
|
|
);
|
|
let _ = sock.write_all(resp.as_bytes()).await;
|
|
let _ = sock.write_all(body).await;
|
|
});
|
|
}
|
|
});
|
|
|
|
let client = PlcClient::new(format!("http://{addr}"));
|
|
let r = tokio::time::timeout(
|
|
Duration::from_secs(2),
|
|
client.resolve_handle("did:plc:abc"),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
assert_eq!(r.as_deref(), Some("alice.bsky.social"));
|
|
server.abort();
|
|
}
|
|
|
|
/// Modern PLC DID documents encode the handle in `alsoKnownAs[0]` as
|
|
/// `at://<handle>` instead of a top-level field. Real-world docs
|
|
/// (e.g. Bluesky's) look like this — must be parsed correctly.
|
|
#[tokio::test]
|
|
async fn resolve_handle_parses_alsoKnownAs() {
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let addr = listener.local_addr().unwrap();
|
|
let server = tokio::spawn(async move {
|
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
loop {
|
|
let (mut sock, _) = listener.accept().await.unwrap();
|
|
tokio::spawn(async move {
|
|
let mut buf = vec![0u8; 1024];
|
|
let _ = sock.read(&mut buf).await;
|
|
let body = br#"{"did":"did:plc:abc","alsoKnownAs":["at://alice.bsky.social"],"services":{}}"#;
|
|
let resp = format!(
|
|
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n",
|
|
body.len()
|
|
);
|
|
let _ = sock.write_all(resp.as_bytes()).await;
|
|
let _ = sock.write_all(body).await;
|
|
});
|
|
}
|
|
});
|
|
|
|
let client = PlcClient::new(format!("http://{addr}"));
|
|
let r = tokio::time::timeout(
|
|
Duration::from_secs(2),
|
|
client.resolve_handle("did:plc:abc"),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
assert_eq!(r.as_deref(), Some("alice.bsky.social"));
|
|
server.abort();
|
|
}
|
|
|
|
/// `did:web:` is explicitly out of scope for now. Make sure we
|
|
/// short-circuit with `Ok(None)` and never touch the network.
|
|
#[tokio::test]
|
|
async fn resolve_handle_skips_did_web() {
|
|
// Construct a client pointed at an unreachable address — if our
|
|
// implementation actually tried to hit it, this would time out.
|
|
let client = PlcClient::new("http://127.0.0.1:1");
|
|
let r = tokio::time::timeout(
|
|
Duration::from_millis(200),
|
|
client.resolve_handle("did:web:example.com"),
|
|
)
|
|
.await
|
|
.expect("did:web must not block on the network")
|
|
.unwrap();
|
|
assert!(r.is_none());
|
|
}
|
|
|
|
/// Garbage DIDs (empty suffix, embedded slash) must be rejected
|
|
/// without a network round-trip.
|
|
#[tokio::test]
|
|
async fn resolve_handle_rejects_garbage_did() {
|
|
let client = PlcClient::new("http://127.0.0.1:1");
|
|
for bad in ["did:plc:", "did:plc:/etc/passwd"] {
|
|
let r = client.resolve_handle(bad).await.unwrap();
|
|
assert!(r.is_none(), "{bad} must yield None, got {r:?}");
|
|
}
|
|
}
|
|
}
|