~ knotless Search/
↑↓ move↵ open⇧↵ all results

johnandersen777.bsky.social/atlibs

0
0
main
571 lines · 33.3 KB Raw History
//! OAuth client metadata (the Rust twin of javascript/src/oauth) and the handler an existing server
//! mounts to serve it dynamically, as the emdash atproto-integrations plugin serves
//! /integrations/<did>/<rkey>/oauth-client-metadata.json from com.fedfork.exec.oauth.clientMetadata
//! records.
//!
//! ```ignore
//! let handler = ClientMetadataHandler::from_stage(&stage_json); // `atlibs stage --target server` output
//! // hyper: if let Some(res) = handler.handle(&req) { return Ok(res) }
//! // axum:  .route("/oauth/{session}/client-metadata.json", get(move |req: Request| async move { handler.handle(&req) }))
//! ```

use std::sync::Arc;

use serde_json::{Map, Value, json};

use crate::BoxFut;
use crate::host::service_aud;
use crate::run::{Call, Request, Sub};

pub const BASE_SCOPE: &str = "atproto";
pub const COLLECTION: &str = "com.fedfork.exec.oauth.clientMetadata";
/// The origin `atlibs stage --target spa` writes when no --origin is given: one literal to replace
/// with the deployed origin (no trailing slash) in stage.json and the client documents.
pub const TEMPLATE_ORIGIN: &str = "https://atlibs.invalid";
const REBASED: [&str; 4] = ["client_uri", "logo_uri", "tos_uri", "policy_uri"];
const LOOPBACK: [&str; 3] = ["localhost", "127.0.0.1", "[::1]"];

pub fn scope_list(scope: &str) -> Vec<&str> {
    scope.split(' ').filter(|s| !s.is_empty()).collect()
}

fn origin_of(url: &str) -> Result<String, String> {
    let parsed = url::Url::parse(url).map_err(|_| format!("{url} is not a URL"))?;
    Ok(parsed.origin().ascii_serialization())
}

/// The client metadata document served at `client_id` for a record (JS clientMetadata): URLs on
/// the template's origin move to the site's origin; always public and DPoP-bound.
pub fn client_metadata(record: &Value, client_id: &str, redirect_uri: &str) -> Result<Value, String> {
    let meta = &record["clientMetadata"];
    let scope = meta["scope"].as_str().unwrap_or_default();
    let name = record["name"].as_str().unwrap_or_default();
    if !scope_list(scope).contains(&BASE_SCOPE) {
        return Err(format!("{name} scope must include {BASE_SCOPE}"));
    }
    let template = origin_of(meta["client_id"].as_str().unwrap_or_default())?;
    let site = origin_of(client_id)?;
    let mut doc = Map::new();
    doc.insert("client_name".into(), meta.get("client_name").cloned().unwrap_or_else(|| json!(name)));
    for field in REBASED {
        if let Some(rest) = meta[field].as_str().and_then(|v| v.strip_prefix(&template)) {
            doc.insert(field.into(), json!(format!("{site}{rest}")));
        }
    }
    doc.extend(
        json!({
            "client_id": client_id,
            "redirect_uris": [redirect_uri],
            "scope": scope,
            "grant_types": ["authorization_code", "refresh_token"],
            "response_types": ["code"],
            "application_type": "web",
            "token_endpoint_auth_method": "none",
            "dpop_bound_access_tokens": true,
        })
        .as_object()
        .cloned()
        .unwrap_or_default(),
    );
    Ok(Value::Object(doc))
}

/// Records composed into one record of the same lexicon (JS composeRecord): the union of their
/// scopes, their scope groups keyed `<session>.<key>`.
pub fn compose_record(records: &Map<String, Value>, name: &str, client_id: &str, redirect_uri: &str, created_at: &str) -> Result<Value, String> {
    let mut scopes = vec![BASE_SCOPE.to_string()];
    for record in records.values() {
        for s in scope_list(record["clientMetadata"]["scope"].as_str().unwrap_or_default()) {
            if !scopes.iter().any(|x| x == s) {
                scopes.push(s.into());
            }
        }
    }
    let names: Vec<&str> = records.values().filter_map(|r| r["name"].as_str()).collect();
    let groups: Vec<Value> = records
        .iter()
        .flat_map(|(session, record)| {
            record["scopeGroups"].as_array().cloned().unwrap_or_default().into_iter().map(move |g| {
                let mut group = json!({ "key": format!("{session}.{}", g["key"].as_str().unwrap_or_default()), "label": g["label"], "scopes": g["scopes"] });
                if g["optional"] == true {
                    group["optional"] = json!(true);
                }
                group
            })
        })
        .collect();
    Ok(json!({
        "$type": COLLECTION,
        "name": name,
        "description": format!("{} in one sign-in.", names.join(", ")),
        "clientMetadata": {
            "client_id": client_id,
            "client_name": name,
            "client_uri": origin_of(client_id)?,
            "redirect_uris": [redirect_uri],
            "grant_types": ["authorization_code", "refresh_token"],
            "response_types": ["code"],
            "scope": scopes.join(" "),
            "application_type": "web",
            "token_endpoint_auth_method": "none",
            "dpop_bound_access_tokens": true,
        },
        "scopeGroups": groups,
        "createdAt": created_at,
    }))
}

/// Serves `<path><session>/client-metadata.json` for each record, the client_id being that URL on
/// `origin` (on a loopback Host, the request's origin when `origin` is unset). With `name`,
/// `<path><name>/client-metadata.json` is the composite of all of them.
#[derive(Clone, Debug, Default)]
pub struct ClientMetadataHandler {
    /// session (record key) -> com.fedfork.exec.oauth.clientMetadata record
    pub records: Map<String, Value>,
    /// URL path prefix, default "/oauth/"
    pub path: Option<String>,
    /// the public origin (https://site); unset: only loopback Hosts are answered (http://<Host>)
    pub origin: Option<String>,
    /// default: the origin + "/"
    pub redirect_uri: Option<String>,
    /// composite client name
    pub name: Option<String>,
}

impl ClientMetadataHandler {
    /// From `atlibs stage --target server` stage.json or an atlibs.lock.json ({ sessions: { <rkey>:
    /// { record } } }), or a plain { <rkey>: record } map.
    pub fn from_stage(source: &Value) -> Self {
        let records = match source.get("sessions").and_then(Value::as_object) {
            Some(sessions) => sessions.iter().filter_map(|(k, s)| s.get("record").map(|r| (k.clone(), r.clone()))).collect(),
            None => source.as_object().cloned().unwrap_or_default(),
        };
        Self { records, path: source.get("oauthPath").and_then(Value::as_str).map(String::from), ..Default::default() }
    }

    fn prefix(&self) -> String {
        let p = self.path.as_deref().unwrap_or("/oauth/");
        let p = if p.starts_with('/') { p.to_string() } else { format!("/{p}") };
        if p.ends_with('/') { p } else { format!("{p}/") }
    }

    /// The client_id for a session (or the composite name) at an origin.
    pub fn client_id(&self, origin: &str, session: &str) -> String {
        format!("{}{}{session}/client-metadata.json", origin.trim_end_matches('/'), self.prefix())
    }

    /// The document for a session at an origin; None when there is no such session.
    pub fn document(&self, origin: &str, session: &str) -> Option<Result<Value, String>> {
        let client_id = self.client_id(origin, session);
        let redirect = self.redirect_uri.clone().unwrap_or_else(|| format!("{}/", origin.trim_end_matches('/')));
        if let Some(record) = self.records.get(session) {
            return Some(client_metadata(record, &client_id, &redirect));
        }
        if self.name.as_deref() == Some(session) {
            return Some(compose_record(&self.records, session, &client_id, &redirect, "1970-01-01T00:00:00.000Z").and_then(|r| client_metadata(&r, &client_id, &redirect)));
        }
        None
    }

    /// The origin a request reached: `origin`, else `http://<Host>` when Host is loopback. A
    /// non-loopback Host (or X-Forwarded-*) is never trusted: a public deployment sets `origin`.
    pub fn request_origin<B>(&self, req: &http::Request<B>) -> Option<String> {
        if let Some(o) = &self.origin {
            return Some(o.trim_end_matches('/').to_string());
        }
        let host = req.headers().get("host").and_then(|v| v.to_str().ok()).or_else(|| req.uri().authority().map(|a| a.as_str()))?;
        let hostname = host.rsplit_once(':').filter(|(h, p)| !h.is_empty() && p.bytes().all(|b| b.is_ascii_digit())).map_or(host, |(h, _)| h);
        LOOPBACK.contains(&hostname).then(|| format!("http://{host}"))
    }

    /// Answers GET/HEAD `<path><session>/client-metadata.json`; None for any other request, so a
    /// server falls through to its own routes.
    pub fn handle<B>(&self, req: &http::Request<B>) -> Option<http::Response<String>> {
        let session = req.uri().path().strip_prefix(&self.prefix())?.strip_suffix("/client-metadata.json")?;
        if session.contains('/') || !matches!(*req.method(), http::Method::GET | http::Method::HEAD) {
            return None;
        }
        let respond = |status: u16, body: Value| {
            http::Response::builder()
                .status(status)
                .header("content-type", "application/json")
                .header("cache-control", "public, max-age=60")
                .body(body.to_string())
                .ok()
        };
        let Some(origin) = self.request_origin(req) else {
            return respond(500, json!({ "error": "InternalServerError", "message": "no origin configured: set origin (atlibs serve --origin / ATLIBS_ORIGIN); only loopback hosts are read from the request" }));
        };
        match self.document(&origin, session) {
            Some(Ok(doc)) => respond(200, doc),
            Some(Err(e)) => respond(500, json!({ "error": "InternalServerError", "message": e })),
            None => respond(404, json!({ "error": "NOT_FOUND" })),
        }
    }
}

/// What a call needs from a session (JS permissions): a repo collection + action, an rpc lxm + aud, or blobs.
#[derive(Debug, PartialEq)]
pub enum Need {
    /// collection (None: missing, granted by transition:generic only), action (from an applyWrites $type)
    Repo(Option<String>, Option<String>),
    Rpc(String, String),
    Blob,
}

const ACTIONS: [&str; 3] = ["create", "update", "delete"];

/// None: the call needs no scope (DID-service and public reads, host pseudo-calls, a subscribe with no
/// serviceAuth); else every one of these (JS permissions).
pub fn permissions(nsid: &str, request: &Request, subscribe: bool) -> Option<Vec<Need>> {
    let service = request.service.as_deref().filter(|s| !s.is_empty());
    // did#id with endpoints: a service-auth call aud did#id (transport::TransportCall, host::HostCall)
    if let (Some(aud), Some(_)) = (service, &request.endpoints) {
        return Some(vec![Need::Rpc(nsid.into(), aud.into())]);
    }
    if service.is_some_and(|s| s.starts_with("did:")) || nsid.starts_with("com.fedfork.exec.") || (subscribe && request.service_auth.is_none()) {
        return None;
    }
    if let Some(proxy) = request.proxy.as_deref().filter(|p| !p.is_empty()) {
        return Some(vec![Need::Rpc(nsid.into(), proxy.into())]);
    }
    if let Some(service) = service {
        return Some(vec![Need::Rpc(nsid.into(), service_aud(service))]);
    }
    // a token for that lxm and aud: what the PDS checks an OAuth session for (an http allowlist's serviceAuth)
    if nsid == "com.atproto.server.getServiceAuth" {
        let param = |k: &str| request.params.as_ref().and_then(|p| p.get(k)).and_then(Value::as_str).unwrap_or_default().to_string();
        return Some(vec![Need::Rpc(param("lxm"), param("aud"))]);
    }
    let collection = |v: &Value| v["collection"].as_str().filter(|c| !c.is_empty()).map(String::from);
    let input = request.input.as_ref().unwrap_or(&Value::Null);
    let write = |actions: &[&str]| Some(actions.iter().map(|a| Need::Repo(collection(input), Some(a.to_string()))).collect());
    match nsid {
        "com.atproto.repo.createRecord" => write(&["create"]),
        "com.atproto.repo.putRecord" => write(&["create", "update"]),
        "com.atproto.repo.deleteRecord" => write(&["delete"]),
        "com.atproto.repo.applyWrites" => Some(input["writes"].as_array().into_iter().flatten().map(|w| Need::Repo(collection(w), w["$type"].as_str().and_then(|t| t.split('#').nth(1)).map(String::from))).collect()),
        "com.atproto.repo.uploadBlob" => Some(vec![Need::Blob]),
        _ if ["com.atproto.repo.getRecord", "com.atproto.repo.listRecords", "com.atproto.repo.describeRepo", "com.atproto.sync."].iter().any(|p| nsid.starts_with(p)) || nsid == "com.atproto.identity.resolveHandle" => None,
        _ => Some(vec![Need::Rpc(nsid.into(), "*".into())]),
    }
}

enum Granted {
    Repo(Vec<String>, Vec<String>),
    Rpc(Vec<String>, Vec<String>),
    Generic,
    Blob,
    Other,
}

fn parse_scope(item: &str) -> Granted {
    let (head, query) = item.split_once('?').unwrap_or((item, ""));
    let (resource, positional) = match head.split_once(':') {
        Some((r, p)) => (r, Some(p).filter(|p| !p.is_empty())),
        None => (head, None),
    };
    let all = |key: &str| url::form_urlencoded::parse(query.as_bytes()).filter(|(k, _)| k == key).map(|(_, v)| v.into_owned()).collect::<Vec<_>>();
    let named = |key: &str| positional.map_or_else(|| all(key), |p| vec![p.to_string()]);
    match resource {
        "repo" => {
            let actions = all("action");
            Granted::Repo(named("collection"), if actions.is_empty() { ACTIONS.map(String::from).to_vec() } else { actions })
        }
        "rpc" => Granted::Rpc(named("lxm"), all("aud")),
        _ if item.starts_with("transition:generic") => Granted::Generic,
        "blob" => Granted::Blob,
        _ => Granted::Other,
    }
}

fn grants(scope: &Granted, need: &Need) -> bool {
    let any_of = |list: &[String], v: &str| list.iter().any(|x| x == "*" || x == v);
    match (scope, need) {
        (Granted::Generic, Need::Rpc(lxm, _)) => !lxm.starts_with("chat.bsky."),
        (Granted::Generic, _) => true,
        (Granted::Repo(collections, actions), Need::Repo(Some(c), Some(a))) => any_of(collections, c) && actions.contains(a),
        (Granted::Rpc(lxms, auds), Need::Rpc(lxm, aud)) => any_of(lxms, lxm) && (aud == "*" || any_of(auds, aud)),
        (Granted::Blob, Need::Blob) => true,
        _ => false,
    }
}

/// Whether a scope string lets a session make this call (JS scopeAllows).
pub fn scope_allows(scope: &str, nsid: &str, request: &Request, subscribe: bool) -> bool {
    let Some(needs) = permissions(nsid, request, subscribe) else { return true };
    let granted: Vec<Granted> = scope_list(scope).into_iter().map(parse_scope).collect();
    !needs.is_empty() && needs.iter().all(|n| granted.iter().any(|g| grants(g, n)))
}

/// A signed-in run's call held to its module's own session (JS server
/// sessionCall, i.e. policyCall with the run's account as the one connected session): a call goes through when
/// `session`'s scope allows it, or when it needs no scope; else it is refused with policyCall's text.
pub struct ScopeCall {
    /// the module's key (its rkey, lockfile key, directory name or wasm file stem)
    pub module: String,
    /// its session record: (rkey, scope); None when it has none
    pub session: Option<(String, String)>,
    pub inner: Arc<dyn Call>,
}

impl ScopeCall {
    fn check(&self, nsid: &str, request: &Request, subscribe: bool) -> Result<(), String> {
        let allowed = match &self.session {
            Some((_, scope)) => scope_allows(scope, nsid, request, subscribe),
            None => permissions(nsid, request, subscribe).is_none(),
        };
        if allowed {
            return Ok(());
        }
        let with = self.session.as_ref().map_or_else(|| "no connected session".to_string(), |(name, _)| format!("sessions {name}"));
        Err(format!("{} may not call {nsid} with {with}", self.module))
    }
}

impl Call for ScopeCall {
    fn call(&self, nsid: String, request: Request) -> BoxFut<'_, Result<Option<Value>, String>> {
        Box::pin(async move {
            self.check(&nsid, &request, request.subscribe)?;
            self.inner.call(nsid, request).await
        })
    }
    fn subscribe(&self, nsid: String, request: Request) -> BoxFut<'_, Result<Sub, String>> {
        Box::pin(async move {
            self.check(&nsid, &request, true)?;
            self.inner.subscribe(nsid, request).await
        })
    }
}

/// JS appRepo: an app's own identity, did:web:<origin's host>, and the read-only repo its session records
/// declare (`app`: { records: [{ collection, rkey, value }], wellKnown: { file: JSON } }), rebased from each
/// record's template origin (its client_id's) to origin. The key is a P-256 did:key the host keeps.
#[derive(Clone, Debug)]
pub struct AppRepo {
    pub did: String,
    pub document: Value,
    /// (collection, rkey, uri, cid, value)
    pub records: Vec<(String, String, String, String, Value)>,
    pub well_known: Map<String, Value>,
}

const APP_READS: [&str; 3] = ["com.atproto.repo.getRecord", "com.atproto.repo.listRecords", "com.atproto.repo.describeRepo"];

impl AppRepo {
    /// None when no session record declares `app`.
    pub fn new(records: &Map<String, Value>, origin: &str, public_key_multibase: &str) -> Result<Option<Self>, String> {
        if !records.values().any(|r| r.get("app").is_some()) {
            return Ok(None);
        }
        let site = url::Url::parse(origin).map_err(|_| format!("{origin} is not a URL"))?;
        let host = |u: &url::Url| format!("{}{}", u.host_str().unwrap_or_default(), u.port().map_or(String::new(), |p| format!("%3A{p}")));
        if site.scheme() != "https" || LOOPBACK.contains(&site.host_str().unwrap_or_default()) {
            return Err(format!("an app identity needs a public https origin (a did:web resolves over https), not {origin}"));
        }
        let did = format!("did:web:{}", host(&site));
        let site_origin = site.origin().ascii_serialization();
        let mut out = Self { did: did.clone(), document: Value::Null, records: vec![], well_known: Map::new() };
        for (session, record) in records {
            let template = url::Url::parse(record["clientMetadata"]["client_id"].as_str().unwrap_or_default()).map_err(|_| format!("{session}: client_id is not a URL"))?;
            let (template_origin, template_did) = (template.origin().ascii_serialization(), format!("did:web:{}", host(&template)));
            fn rebase(v: &Value, from: &str, to: &str, from_did: &str, did: &str) -> Value {
                match v {
                    Value::String(s) if s == from_did => json!(did),
                    Value::String(s) if s.starts_with(from) => json!(format!("{to}{}", &s[from.len()..])),
                    Value::Array(a) => Value::Array(a.iter().map(|x| rebase(x, from, to, from_did, did)).collect()),
                    Value::Object(o) => Value::Object(o.iter().map(|(k, x)| (k.clone(), rebase(x, from, to, from_did, did))).collect()),
                    other => other.clone(),
                }
            }
            let rebased = |v: &Value| rebase(v, &template_origin, &site_origin, &template_did, &did);
            for r in record["app"]["records"].as_array().into_iter().flatten() {
                let (collection, rkey) = (r["collection"].as_str().unwrap_or_default(), r["rkey"].as_str().unwrap_or_default());
                let ok_collection = !collection.is_empty() && collection.chars().all(|c| c.is_ascii_alphanumeric() || ".-".contains(c));
                let ok_rkey = (1..=512).contains(&rkey.len()) && rkey.chars().all(|c| c.is_ascii_alphanumeric() || "._:~-".contains(c));
                if !ok_collection || !ok_rkey || !r["value"].is_object() {
                    return Err(format!("{session}: an app record is {{ collection, rkey, value }}"));
                }
                let value = rebased(&r["value"]);
                if let Some(same) = out.records.iter().find(|x| x.0 == collection && x.1 == rkey) {
                    if same.4 != value {
                        return Err(format!("{session}: {collection}/{rkey} is declared twice, differently"));
                    }
                    continue;
                }
                let mut encoded = vec![];
                crate::crypto::dag_cbor(&value, &mut encoded)?;
                use sha2::Digest;
                let cid = format!("b{}", crate::crypto::base32(&[&[0x01, 0x71, 0x12, 0x20][..], &sha2::Sha256::digest(&encoded)].concat()));
                out.records.push((collection.into(), rkey.into(), format!("at://{did}/{collection}/{rkey}"), cid, value));
            }
            for (file, value) in record["app"]["wellKnown"].as_object().into_iter().flatten() {
                if file.is_empty() || !file.chars().all(|c| c.is_ascii_alphanumeric() || "._-".contains(c)) || file == "did.json" || file == "atproto-did" {
                    return Err(format!("{session}: app wellKnown {file} is not a file name it may serve"));
                }
                out.well_known.insert(file.clone(), rebased(value));
            }
        }
        out.document = json!({
            "@context": ["https://www.w3.org/ns/did/v1", "https://w3id.org/security/multikey/v1"],
            "id": did,
            "verificationMethod": [{ "id": format!("{did}#atproto"), "type": "Multikey", "controller": did, "publicKeyMultibase": public_key_multibase }],
            "service": [{ "id": "#atproto_pds", "type": "AtprotoPersonalDataServer", "serviceEndpoint": site_origin }],
        });
        Ok(Some(out))
    }

    /// The host (with port) the DID names: requests to any other Host are not this repo's.
    pub fn host(&self) -> String {
        self.did["did:web:".len()..].replace("%3A", ":")
    }

    /// JS appRepoAnswer: (status, content-type, body) for the identity's well-known files and the repo reads;
    /// None for anything else. Read-only: no createAccount, getServiceAuth, writes or sync.
    pub fn answer(&self, path_and_query: &str) -> Option<(u16, &'static str, String)> {
        let url = url::Url::parse(&format!("http://app.invalid{path_and_query}")).ok()?;
        let json = |status: u16, body: Value| Some((status, "application/json", body.to_string()));
        let path = url.path();
        if path == "/.well-known/did.json" {
            return json(200, self.document.clone());
        }
        if path == "/.well-known/atproto-did" {
            return Some((200, "text/plain", self.did.clone()));
        }
        if let Some(value) = path.strip_prefix("/.well-known/").and_then(|f| self.well_known.get(f)) {
            return json(200, value.clone());
        }
        let nsid = path.strip_prefix("/xrpc/").filter(|n| APP_READS.contains(n))?;
        let q = |k: &str| url.query_pairs().find(|(n, _)| n == k).map(|(_, v)| v.into_owned()).unwrap_or_default();
        if q("repo") != self.did && q("repo") != self.host() {
            return json(400, json!({ "error": "RepoNotFound", "message": format!("this PDS holds only {}", self.did) }));
        }
        let out = |r: &(String, String, String, String, Value)| json!({ "uri": r.2, "cid": r.3, "value": r.4 });
        match nsid {
            "com.atproto.repo.describeRepo" => {
                let mut collections: Vec<&str> = vec![];
                for r in &self.records {
                    if !collections.contains(&r.0.as_str()) {
                        collections.push(&r.0);
                    }
                }
                json(200, json!({ "handle": self.host(), "did": self.did, "didDoc": self.document, "collections": collections, "handleIsCorrect": true }))
            }
            "com.atproto.repo.listRecords" => json(200, json!({ "records": self.records.iter().filter(|r| r.0 == q("collection")).map(out).collect::<Vec<_>>() })),
            _ => match self.records.iter().find(|r| r.0 == q("collection") && r.1 == q("rkey")) {
                Some(r) => json(200, out(r)),
                None => json(400, json!({ "error": "RecordNotFound", "message": format!("Could not locate record: at://{}/{}/{}", self.did, q("collection"), q("rkey")) })),
            },
        }
    }

    /// answer() for a GET/HEAD whose Host is this repo's (public reads: CORS *); None otherwise.
    pub fn handle<B>(&self, req: &http::Request<B>) -> Option<http::Response<String>> {
        let host = req.headers().get("host").and_then(|v| v.to_str().ok())?;
        if host != self.host() || !matches!(*req.method(), http::Method::GET | http::Method::HEAD) {
            return None;
        }
        let (status, kind, body) = self.answer(req.uri().path_and_query().map_or("/", |p| p.as_str()))?;
        http::Response::builder().status(status).header("content-type", kind).header("access-control-allow-origin", "*").header("cache-control", "public, max-age=60").body(body).ok()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn record(name: &str, scope: &str) -> Value {
        json!({ "$type": COLLECTION, "name": name, "clientMetadata": { "client_id": "https://tpl.example/c.json", "client_uri": "https://tpl.example/about", "scope": scope }, "scopeGroups": [{ "key": "k", "label": "K", "scopes": [scope] }], "createdAt": "x" })
    }

    fn get(handler: &ClientMetadataHandler, host: &str, path: &str) -> Option<(u16, Value)> {
        let req = http::Request::get(path).header("host", host).body(()).unwrap();
        handler.handle(&req).map(|r| (r.status().as_u16(), serde_json::from_str(r.body()).unwrap()))
    }

    #[test]
    fn serves_per_session_and_composite_metadata_on_the_configured_origin() {
        let stage = json!({ "sessions": { "atirc": { "record": record("atirc", "atproto rpc:a") }, "fedproxy": { "record": record("FedProxy", "atproto repo:b") } } });
        let handler = ClientMetadataHandler { name: Some("ui".into()), ..ClientMetadataHandler::from_stage(&stage) };
        assert_eq!(get(&handler, "site.example", "/oauth/atirc/client-metadata.json").unwrap().0, 500, "a public Host is never trusted");
        let handler = ClientMetadataHandler { origin: Some("https://site.example".into()), ..handler };
        let (status, doc) = get(&handler, "evil.example", "/oauth/atirc/client-metadata.json").unwrap();
        assert_eq!(status, 200);
        assert_eq!(doc["client_id"], "https://site.example/oauth/atirc/client-metadata.json");
        assert_eq!(doc["redirect_uris"], json!(["https://site.example/"]));
        assert_eq!(doc["client_uri"], "https://site.example/about");
        assert_eq!(doc["scope"], "atproto rpc:a");
        let (_, ui) = get(&ClientMetadataHandler { origin: None, ..handler.clone() }, "127.0.0.1:8787", "/oauth/ui/client-metadata.json").unwrap();
        assert_eq!(ui["client_id"], "http://127.0.0.1:8787/oauth/ui/client-metadata.json");
        assert_eq!(ui["scope"], "atproto rpc:a repo:b");
        assert_eq!(get(&handler, "site.example", "/oauth/nope/client-metadata.json").unwrap().0, 404);
        assert!(get(&handler, "site.example", "/other").is_none());
        assert!(get(&handler, "site.example", "/oauth/a/b/client-metadata.json").is_none());
        let fixed = ClientMetadataHandler { origin: Some("https://fixed.example/".into()), path: Some("auth".into()), ..handler };
        assert_eq!(get(&fixed, "x", "/auth/fedproxy/client-metadata.json").unwrap().1["client_id"], "https://fixed.example/auth/fedproxy/client-metadata.json");
    }

    #[test]
    fn app_repo_serves_the_rebased_records_with_the_js_cids() {
        let registration = json!({ "$type": "dev.cocore.app.registration", "name": "App", "website": "https://app.example", "returnUrls": ["https://app.example/done"], "createdAt": "2026-11-10T00:00:00.000Z" });
        let record = json!({ "clientMetadata": { "client_id": "https://app.example/oauth/app/client-metadata.json", "scope": "atproto" }, "app": { "records": [{ "collection": "dev.cocore.app.registration", "rkey": "self", "value": registration }], "wellKnown": { "cocore-app.json": { "did": "did:web:app.example" } } } });
        let records = json!({ "app": record, "plain": { "clientMetadata": { "client_id": "https://p.example/c.json" } } }).as_object().cloned().unwrap();
        let repo = AppRepo::new(&records, "https://deploy.example", "zDnaeKey").unwrap().unwrap();
        assert_eq!(repo.did, "did:web:deploy.example");
        // the same CID as javascript/src/oauth appRepo's test
        assert_eq!(repo.records[0].3, "bafyreiact3y3dp5yjllsptqtj57iv4kuzvo4rtxvpzakl7el5qgx6fp6nm");
        assert_eq!(repo.records[0].4["returnUrls"], json!(["https://deploy.example/done"]));
        let body = |path: &str| serde_json::from_str::<Value>(&repo.answer(path).unwrap().2).unwrap();
        assert_eq!(body("/.well-known/cocore-app.json"), json!({ "did": "did:web:deploy.example" }));
        assert_eq!(body("/.well-known/did.json")["service"][0]["serviceEndpoint"], "https://deploy.example");
        assert_eq!(body("/xrpc/com.atproto.repo.getRecord?repo=did:web:deploy.example&collection=dev.cocore.app.registration&rkey=self")["value"]["website"], "https://deploy.example");
        assert_eq!(body("/xrpc/com.atproto.repo.getRecord?repo=deploy.example&collection=dev.cocore.app.registration&rkey=nope")["error"], "RecordNotFound");
        assert_eq!(body("/xrpc/com.atproto.repo.listRecords?repo=did:plc:x&collection=y")["error"], "RepoNotFound");
        assert_eq!(repo.answer("/.well-known/atproto-did").unwrap(), (200, "text/plain", "did:web:deploy.example".to_string()));
        for path in ["/xrpc/com.atproto.server.getServiceAuth?aud=x", "/xrpc/com.atproto.server.createAccount", "/xrpc/com.atproto.sync.getRepo", "/"] {
            assert!(repo.answer(path).is_none(), "{path}");
        }
        let get = |host: &str| repo.handle(&http::Request::get("/.well-known/did.json").header("host", host).body(()).unwrap());
        assert_eq!(get("deploy.example").unwrap().headers()["access-control-allow-origin"], "*");
        assert!(get("elsewhere.example").is_none());
        assert!(AppRepo::new(&records, "http://127.0.0.1:8787", "z").unwrap_err().contains("public https origin"));
        assert!(AppRepo::new(&json!({ "plain": records["plain"] }).as_object().cloned().unwrap(), "https://deploy.example", "z").unwrap().is_none());
    }

    #[test]
    fn scope_allows_maps_calls_to_repo_rpc_and_blob_permissions() {
        let req = |v: Value| Request { input: v.get("input").cloned(), proxy: v["proxy"].as_str().map(String::from), service: v["service"].as_str().map(String::from), service_auth: v.get("serviceAuth").cloned(), ..Default::default() };
        let create = |c: &str| req(json!({ "input": { "collection": c } }));
        let scope = "atproto repo:a.b.c?action=create&action=delete rpc:x.y.z?aud=did:web:svc.example%23x blob:*/*";
        assert!(scope_allows(scope, "com.atproto.repo.createRecord", &create("a.b.c"), false));
        assert!(!scope_allows(scope, "com.atproto.repo.createRecord", &create("d.e.f"), false));
        assert!(!scope_allows(scope, "com.atproto.repo.putRecord", &create("a.b.c"), false), "put needs create and update");
        assert!(scope_allows("atproto repo:a.b.c", "com.atproto.repo.putRecord", &create("a.b.c"), false));
        assert!(!scope_allows(scope, "com.atproto.repo.createRecord", &req(json!({ "input": {} })), false), "no collection");
        assert!(scope_allows(scope, "x.y.z", &req(json!({ "proxy": "did:web:svc.example#x" })), false));
        assert!(!scope_allows(scope, "x.y.z", &req(json!({ "proxy": "did:web:evil.example#x" })), false));
        assert!(scope_allows("atproto rpc?lxm=x.y.z&aud=did:web:svc.example%253A8443", "x.y.z", &req(json!({ "service": "https://svc.example:8443" })), false));
        // getServiceAuth needs rpc:<lxm>?aud=<aud> (the PDS's rule for an OAuth session)
        let mint = |aud: &str, lxm: &str| Request { params: json!({ "aud": aud, "lxm": lxm }).as_object().cloned(), ..Default::default() };
        let cocore = "atproto rpc:dev.cocore.inference.dispatch?aud=did:web:cocore.dev";
        assert!(scope_allows(cocore, "com.atproto.server.getServiceAuth", &mint("did:web:cocore.dev", "dev.cocore.inference.dispatch"), false));
        assert!(!scope_allows(cocore, "com.atproto.server.getServiceAuth", &mint("did:web:evil.example", "dev.cocore.inference.dispatch"), false));
        assert!(!scope_allows(cocore, "com.atproto.server.getServiceAuth", &mint("did:web:cocore.dev", "x.y.z"), false));
        assert!(!scope_allows("atproto rpc:com.atproto.server.getServiceAuth?aud=*", "com.atproto.server.getServiceAuth", &mint("did:web:cocore.dev", "x.y.z"), false));
        assert!(scope_allows(scope, "com.atproto.repo.uploadBlob", &req(json!({ "input": {} })), false) && !scope_allows("atproto", "com.atproto.repo.uploadBlob", &req(json!({ "input": {} })), false));
        let writes = req(json!({ "input": { "writes": [{ "$type": "com.atproto.repo.applyWrites#delete", "collection": "a.b.c" }] } }));
        assert!(scope_allows("atproto repo?collection=a.b.c&action=delete", "com.atproto.repo.applyWrites", &writes, false));
        assert!(!scope_allows("atproto", "com.atproto.repo.applyWrites", &req(json!({ "input": { "writes": [] } })), false));
        assert!(scope_allows("atproto", "com.atproto.repo.listRecords", &req(json!({})), false), "reads need no scope");
        assert!(scope_allows("atproto", "com.fedfork.exec.attest", &req(json!({})), false), "host pseudo-calls need none");
        assert!(scope_allows("atproto", "com.atproto.sync.subscribeRepos", &req(json!({ "service": "https://relay.example" })), true), "a public stream");
        assert!(!scope_allows("atproto", "a.b.watch", &req(json!({ "service": "https://svc.example", "serviceAuth": {} })), true));
        assert!(scope_allows("atproto transition:generic", "com.atproto.repo.createRecord", &create("any.thing"), false));
        assert!(!scope_allows("atproto transition:generic", "chat.bsky.convo.sendMessage", &req(json!({ "input": {} })), false));
    }

    #[test]
    fn refuses_records_without_atproto_scope() {
        assert!(client_metadata(&record("x", "repo:a"), "https://s/c", "https://s/").is_err());
    }
}