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.
204 lines
7.0 KiB
Rust
204 lines
7.0 KiB
Rust
//! DID-to-handle resolution for the `did:web:` method.
|
|
//!
|
|
//! A `did:web:` DID names a host that publishes its DID document at a
|
|
//! well-known URL. The document in turn encodes the current handle as
|
|
//! the first `alsoKnownAs` AT URI (`at://<handle>`). The PLC directory
|
|
//! has no idea about these DIDs, so without this module the AppView's
|
|
//! handle-sync worker would leave every `did:web:` post stuck on
|
|
//! `@<did-prefix>…` forever.
|
|
//!
|
|
//! URL shape (per the did:web spec, https://w3c-ccg.github.io/did-method-web):
|
|
//! did:web:example.com -> https://example.com/.well-known/did.json
|
|
//! did:web:example.com:user:alice -> https://example.com/user/alice/did.json
|
|
//!
|
|
//! Anything else — non-2xx, garbage body, no `alsoKnownAs` — collapses
|
|
//! to `Ok(None)` so a misconfigured remote can't fail the worker.
|
|
|
|
use anyhow::Result;
|
|
use async_trait::async_trait;
|
|
use reqwest::Client;
|
|
use serde_json::Value;
|
|
|
|
use crate::handle::DidHandleResolver;
|
|
|
|
#[derive(Clone)]
|
|
pub struct WebResolver {
|
|
pub client: Client,
|
|
/// URL scheme for the resolved well-known document. Production
|
|
/// uses `"https"`; tests can flip this to `"http"` so a plain
|
|
/// mock TCP listener can stand in for a real PDS.
|
|
pub scheme: String,
|
|
}
|
|
|
|
impl WebResolver {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
client: Client::builder()
|
|
.timeout(std::time::Duration::from_secs(10))
|
|
.build()
|
|
.unwrap(),
|
|
scheme: "https".to_string(),
|
|
}
|
|
}
|
|
|
|
/// Build the did:web URL for a given DID. Returns `None` when the
|
|
/// DID is empty, contains a traversal segment, or otherwise looks
|
|
/// like a URL-injection attempt.
|
|
pub(crate) fn did_to_url(&self, did: &str) -> Option<String> {
|
|
let rest = did.strip_prefix("did:web:")?;
|
|
if rest.is_empty() {
|
|
return None;
|
|
}
|
|
// Per spec, `:` inside the method-specific identifier separates
|
|
// path components. Convert them to `/`. We also reject path
|
|
// traversal (`..`) defensively.
|
|
if rest.split(':').any(|seg| seg.is_empty() || seg == "..") {
|
|
return None;
|
|
}
|
|
let host_path = rest.replace(':', "/");
|
|
Some(format!(
|
|
"{}://{}/.well-known/did.json",
|
|
self.scheme, host_path
|
|
))
|
|
}
|
|
}
|
|
|
|
impl Default for WebResolver {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl DidHandleResolver for WebResolver {
|
|
async fn resolve_handle(&self, did: &str) -> Result<Option<String>> {
|
|
// Anything that isn't `did:web:` is out of scope; let the next
|
|
// resolver (PLC) take a swing instead of returning Err.
|
|
if !did.starts_with("did:web:") {
|
|
return Ok(None);
|
|
}
|
|
let url = match self.did_to_url(did) {
|
|
Some(u) => u,
|
|
None => return Ok(None),
|
|
};
|
|
self.resolve_handle_at_url(&url).await
|
|
}
|
|
}
|
|
|
|
impl WebResolver {
|
|
/// Fetch `url` and parse out the first `at://` handle from the
|
|
/// `alsoKnownAs` array. Public so integration tests can drive
|
|
/// it directly against a mock HTTP listener bound to
|
|
/// `127.0.0.1:PORT` (which can't be expressed as a did:web DID
|
|
/// because the URL builder splits `:` into path segments).
|
|
pub async fn resolve_handle_at_url(
|
|
&self,
|
|
url: &str,
|
|
) -> Result<Option<String>> {
|
|
let resp = match self.client.get(url).send().await {
|
|
Ok(r) => r,
|
|
// Network-level failures are reported as Err so the worker
|
|
// can distinguish "try again later" from "no answer".
|
|
Err(e) => return Err(e.into()),
|
|
};
|
|
|
|
let status = resp.status();
|
|
if status.as_u16() == 404 {
|
|
// DID is syntactically valid but the host doesn't serve a
|
|
// document — same semantics as a missing PLC entry.
|
|
return Ok(None);
|
|
}
|
|
if !status.is_success() {
|
|
// 5xx / weird codes — treat as "no answer". We don't want
|
|
// a broken remote to spam the worker's `failed` counter.
|
|
return Ok(None);
|
|
}
|
|
|
|
// Body might be invalid JSON; treat as Ok(None) instead of
|
|
// bubbling an Err — the worker has no useful retry semantics
|
|
// for malformed bodies.
|
|
let v: Value = match resp.json().await {
|
|
Ok(v) => v,
|
|
Err(_) => return Ok(None),
|
|
};
|
|
|
|
Ok(Self::extract_handle(&v))
|
|
}
|
|
|
|
/// Pull the first `at://` URI out of a DID document's
|
|
/// `alsoKnownAs` array. Returns `None` if the array is missing,
|
|
/// empty, or only contains non-`at://` entries.
|
|
pub(crate) fn extract_handle(v: &Value) -> Option<String> {
|
|
let 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 Some(handle.to_string());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
None
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::time::Duration;
|
|
|
|
fn http_resolver() -> WebResolver {
|
|
WebResolver {
|
|
client: Client::builder()
|
|
.timeout(Duration::from_secs(5))
|
|
.build()
|
|
.unwrap(),
|
|
scheme: "http".to_string(),
|
|
}
|
|
}
|
|
|
|
/// `did:web:pds.maarcadetweet.local` must URL-encode the host
|
|
/// correctly: no path mangling, dots preserved.
|
|
#[tokio::test]
|
|
async fn did_to_url_preserves_dotted_host() {
|
|
let r = WebResolver::new();
|
|
let url = r.did_to_url("did:web:pds.maarcadetweet.local").unwrap();
|
|
assert_eq!(url, "https://pds.maarcadetweet.local/.well-known/did.json");
|
|
}
|
|
|
|
/// Multi-segment DIDs (`did:web:host:user:alice`) map to a nested
|
|
/// path per the spec.
|
|
#[tokio::test]
|
|
async fn did_to_url_handles_path_segments() {
|
|
let r = WebResolver::new();
|
|
let url = r.did_to_url("did:web:example.com:user:alice").unwrap();
|
|
assert_eq!(url, "https://example.com/user/alice/.well-known/did.json");
|
|
}
|
|
|
|
/// Garbage DIDs must short-circuit with `None` and never try to
|
|
/// build a URL we could be tricked into requesting.
|
|
#[tokio::test]
|
|
async fn did_to_url_rejects_garbage() {
|
|
let r = WebResolver::new();
|
|
assert!(r.did_to_url("did:web:").is_none());
|
|
assert!(r.did_to_url("did:web:..").is_none());
|
|
assert!(r.did_to_url("did:web:example.com:..").is_none());
|
|
assert!(r.did_to_url("did:web::empty").is_none());
|
|
}
|
|
|
|
/// Non-`did:web:` DIDs are out of scope; must return `Ok(None)`
|
|
/// without touching the network.
|
|
#[tokio::test]
|
|
async fn resolve_skips_non_web_dids() {
|
|
let resolver = http_resolver();
|
|
let r = tokio::time::timeout(
|
|
Duration::from_millis(200),
|
|
resolver.resolve_handle("did:plc:abc"),
|
|
)
|
|
.await
|
|
.expect("non-did:web must not block on the network")
|
|
.unwrap();
|
|
assert!(r.is_none());
|
|
}
|
|
} |