0
2
server: indexRecords reads a caller's new records back into the index
Only what this pull adds. It is built on grain runs on a Rust server modelled on knotless, without hatk.
crates/grain-serve/src/lib.rs+138−8
| @@ -1,18 +1,95 @@ | ||
//! grain's server. For now it answers only atproto's health check; the | ||
//! index, the queries, ingest, login and push join it pull by pull on | ||
//! `feature/rust`, as the plan orders them. | ||
//! grain's server. For now it answers atproto's health check and | ||
//! `social.grain.sync.indexRecords` ([`sync`]); the queries, ingest, login | ||
//! and push join it pull by pull on `feature/rust`, as the plan orders | ||
//! them. | ||
| ||
pub mod index; | ||
pub mod sync; | ||
pub mod xrpc; | ||
| ||
use axum::http::StatusCode; | ||
use axum::{Json, Router, routing::any, routing::get}; | ||
use axum::{Json, Router, routing::any, routing::get, routing::post}; | ||
use grain::env::Ctx; | ||
use serde_json::{Value, json}; | ||
use std::path::PathBuf; | ||
use std::sync::Arc; | ||
| ||
/// What the server is set to do, from its environment. | ||
#[derive(Debug, Clone, Default, PartialEq)] | ||
pub struct Settings { | ||
/// `GRAIN_INDEX`: the index's file. None keeps no index. | ||
pub index: Option<PathBuf>, | ||
/// `GRAIN_PUBLIC_URL`: where the server is reached, which names its | ||
/// DID ([`service_did`]). None has no DID, and takes no service tokens. | ||
pub public_url: Option<String>, | ||
} | ||
| ||
impl Settings { | ||
pub fn from_vars(get: impl Fn(&str) -> Option<String>) -> Settings { | ||
let set = |name: &str| get(name).filter(|v| !v.is_empty()); | ||
Settings { | ||
index: set("GRAIN_INDEX").map(PathBuf::from), | ||
public_url: set("GRAIN_PUBLIC_URL"), | ||
} | ||
} | ||
| ||
/// The server `ctx` and these settings make: its index opened, and | ||
/// its DID named. | ||
pub fn app(&self, ctx: Ctx) -> anyhow::Result<App> { | ||
let did = self.public_url.as_deref().map(service_did).transpose()?; | ||
let index = match &self.index { | ||
Some(path) => Some(Arc::new(index::Index::open(path)?)), | ||
None => None, | ||
}; | ||
Ok(App { ctx, index, did }) | ||
} | ||
} | ||
| ||
/// What the routes share. | ||
pub struct App { | ||
pub ctx: Ctx, | ||
/// The index, if the server keeps one. | ||
pub index: Option<Arc<index::Index>>, | ||
/// The DID service tokens name the server by in `aud`, if it has one: | ||
/// [`service_did`] of `GRAIN_PUBLIC_URL`. | ||
pub did: Option<String>, | ||
} | ||
| ||
impl App { | ||
/// A server that keeps no index and has no DID: one that answers its | ||
/// health check. | ||
pub fn bare(ctx: Ctx) -> App { | ||
App { | ||
ctx, | ||
index: None, | ||
did: None, | ||
} | ||
} | ||
} | ||
| ||
/// The DID of the server reached at `public_url`, an `https://` or | ||
/// `http://` URL: the `did:web` of its host, as knotless-serve's is of | ||
/// its `--public-url`. `https://grain.social` is `did:web:grain.social`. | ||
pub fn service_did(public_url: &str) -> anyhow::Result<String> { | ||
let url = public_url.trim_end_matches('/'); | ||
let host = url | ||
.strip_prefix("https://") | ||
.or_else(|| url.strip_prefix("http://")) | ||
.filter(|h| !h.is_empty() && !h.contains('/')) | ||
.ok_or_else(|| anyhow::anyhow!("{public_url}: a public URL is http(s)://host[:port]"))?; | ||
Ok(format!("did:web:{}", host.replace(':', "%3A"))) | ||
} | ||
| ||
/// The routes the server answers. | ||
pub fn router() -> Router { | ||
pub fn router(app: Arc<App>) -> Router { | ||
Router::new() | ||
.route("/xrpc/_health", get(health)) | ||
.route( | ||
"/xrpc/social.grain.sync.indexRecords", | ||
post(sync::index_records), | ||
) | ||
.route("/xrpc/{*method}", any(not_implemented)) | ||
.with_state(app) | ||
} | ||
| ||
/// A method the server does not serve, as atproto's XRPC servers refuse it. | ||
| @@ -29,12 +106,65 @@ async fn health() -> Json<Value> { | ||
Json(json!({ "version": env!("CARGO_PKG_VERSION") })) | ||
} | ||
| ||
/// Serves on `listener` until the process is told to stop. | ||
pub async fn serve(listener: tokio::net::TcpListener) -> anyhow::Result<()> { | ||
axum::serve(listener, router()) | ||
/// Serves `app` on `listener` until the process is told to stop. | ||
pub async fn serve(listener: tokio::net::TcpListener, app: Arc<App>) -> anyhow::Result<()> { | ||
axum::serve(listener, router(app)) | ||
.with_graceful_shutdown(async { | ||
let _ = tokio::signal::ctrl_c().await; | ||
}) | ||
.await?; | ||
Ok(()) | ||
} | ||
| ||
#[cfg(test)] | ||
mod tests { | ||
use super::*; | ||
use grain::env::Env; | ||
| ||
#[test] | ||
fn the_did_is_the_public_urls_host() { | ||
let did = |url| service_did(url).unwrap(); | ||
assert_eq!(did("https://grain.social"), "did:web:grain.social"); | ||
assert_eq!(did("https://grain.social/"), "did:web:grain.social"); | ||
assert_eq!(did("http://127.0.0.1:2600"), "did:web:127.0.0.1%3A2600"); | ||
for wrong in ["grain.social", "https://", "https://grain.social/xrpc"] { | ||
let why = service_did(wrong).unwrap_err().to_string(); | ||
assert!(why.starts_with(wrong), "{why}"); | ||
} | ||
} | ||
| ||
#[test] | ||
fn settings_are_read_from_the_environment() { | ||
let unset = Settings::from_vars(|_| None); | ||
assert_eq!(unset, Settings::default()); | ||
let empty = Settings::from_vars(|_| Some(String::new())); | ||
assert_eq!(empty, Settings::default()); | ||
let set = Settings::from_vars(|name| match name { | ||
"GRAIN_INDEX" => Some("/data/index.db".into()), | ||
"GRAIN_PUBLIC_URL" => Some("https://grain.social".into()), | ||
_ => None, | ||
}); | ||
assert_eq!(set.index, Some(PathBuf::from("/data/index.db"))); | ||
assert_eq!(set.public_url.as_deref(), Some("https://grain.social")); | ||
} | ||
| ||
#[test] | ||
fn the_app_opens_its_index_and_names_its_did() { | ||
let ctx = || Ctx::new(Env::from_vars(|_| None)); | ||
let dir = tempfile::tempdir().unwrap(); | ||
let settings = Settings { | ||
index: Some(dir.path().join("index.db")), | ||
public_url: Some("https://grain.social".into()), | ||
}; | ||
let app = settings.app(ctx()).unwrap(); | ||
assert!(app.index.is_some()); | ||
assert_eq!(app.did.as_deref(), Some("did:web:grain.social")); | ||
let bare = Settings::default().app(ctx()).unwrap(); | ||
assert!(bare.index.is_none() && bare.did.is_none()); | ||
let wrong = Settings { | ||
public_url: Some("grain.social".into()), | ||
..Settings::default() | ||
}; | ||
assert!(wrong.app(ctx()).is_err()); | ||
} | ||
} | ||
crates/grain-serve/src/main.rs+9−3
| @@ -1,16 +1,22 @@ | ||
//! `grain-serve`: listens on `GRAIN_LISTEN`, 127.0.0.1:2600 unless set. | ||
//! `grain-serve`: listens on `GRAIN_LISTEN`, 127.0.0.1:2600 unless set, | ||
//! keeping the index at `GRAIN_INDEX` and taking service tokens for the | ||
//! DID of `GRAIN_PUBLIC_URL` when set ([`grain_serve::Settings`]). | ||
| ||
use anyhow::Context; | ||
use grain::env::{Ctx, Env}; | ||
use std::sync::Arc; | ||
| ||
#[tokio::main] | ||
async fn main() -> anyhow::Result<()> { | ||
tracing_subscriber::fmt() | ||
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) | ||
.init(); | ||
let settings = grain_serve::Settings::from_vars(|name| std::env::var(name).ok()); | ||
let app = settings.app(Ctx::new(Env::from_process()))?; | ||
let addr = std::env::var("GRAIN_LISTEN").unwrap_or_else(|_| "127.0.0.1:2600".into()); | ||
let listener = tokio::net::TcpListener::bind(&addr) | ||
.await | ||
.with_context(|| format!("listening on {addr}"))?; | ||
tracing::info!(%addr, "serving"); | ||
grain_serve::serve(listener).await | ||
tracing::info!(%addr, did = ?app.did, "serving"); | ||
grain_serve::serve(listener, Arc::new(app)).await | ||
} | ||
crates/grain-serve/src/sync.rsnew+287−0
| @@ -0,0 +1,287 @@ | ||
//! Records read back from their PDS into the index, as soon as they are | ||
//! written: `social.grain.sync.indexRecords`, which the apps and the | ||
//! command line call after writing to their own PDS, and [`read_back`], | ||
//! which it calls and the web's own writes will too. | ||
//! | ||
//! What the index takes is only ever what the PDS answers. The caller | ||
//! names URIs, never content: each is read with `com.atproto.repo.getRecord` | ||
//! from the PDS the repo's DID document names, and put by its URI and CID, | ||
//! so Jetstream delivering the same record later changes nothing. A record | ||
//! the PDS no longer has is deleted from the index. | ||
| ||
use crate::App; | ||
use crate::index::{Index, PutError, record_uri, records}; | ||
use crate::xrpc::Failure; | ||
use axum::Json; | ||
use axum::body::Bytes; | ||
use axum::extract::State; | ||
use axum::http::{HeaderMap, StatusCode, header}; | ||
use grain::env::Ctx; | ||
use grain::xrpc::{Xrpc, XrpcError}; | ||
use serde::{Deserialize, Serialize}; | ||
use serde_json::Value; | ||
use std::sync::Arc; | ||
| ||
/// The method, as a service token for it names it in `lxm`. | ||
pub const LXM: &str = "social.grain.sync.indexRecords"; | ||
| ||
/// The most URIs one call names, as its lexicon allows. | ||
pub const MAX_URIS: usize = 25; | ||
| ||
/// What reading records back did, as `indexRecords` answers it. | ||
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize)] | ||
#[serde(rename_all = "camelCase")] | ||
pub struct ReadBack { | ||
/// Read back from the PDS and put in the index. | ||
pub indexed: Vec<String>, | ||
/// Not on the PDS: deleted from the index, if it held them. | ||
pub not_found: Vec<String>, | ||
} | ||
| ||
/// Why records were not read back. | ||
#[derive(Debug)] | ||
pub enum ReadBackError { | ||
/// Not a record's AT-URI. | ||
NotARecord(String), | ||
/// A record in another repo than the one being read. | ||
RepoMismatch(String), | ||
/// A record of a collection the index does not keep. | ||
UnknownCollection(String), | ||
/// The PDS failed to answer for the record, other than to say it has | ||
/// no such record. | ||
Pds { | ||
uri: String, | ||
error: anyhow::Error, | ||
}, | ||
/// The PDS has the record, and it does not fit its lexicon. | ||
Refused { | ||
uri: String, | ||
why: String, | ||
}, | ||
Index(rusqlite::Error), | ||
} | ||
| ||
impl std::fmt::Display for ReadBackError { | ||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | ||
match self { | ||
ReadBackError::NotARecord(uri) => write!(f, "{uri} is not a record's AT-URI"), | ||
ReadBackError::RepoMismatch(uri) => write!(f, "{uri} is not in the caller's repo"), | ||
ReadBackError::UnknownCollection(uri) => { | ||
write!(f, "{uri} is of a collection grain does not index") | ||
} | ||
ReadBackError::Pds { uri, error } => write!(f, "reading {uri}: {error:#}"), | ||
ReadBackError::Refused { uri, why } => { | ||
write!(f, "{uri} does not fit its lexicon: {why}") | ||
} | ||
ReadBackError::Index(e) => write!(f, "index: {e}"), | ||
} | ||
} | ||
} | ||
| ||
impl std::error::Error for ReadBackError {} | ||
| ||
impl From<ReadBackError> for Failure { | ||
fn from(e: ReadBackError) -> Failure { | ||
let message = e.to_string(); | ||
let (status, error) = match e { | ||
ReadBackError::NotARecord(_) => (StatusCode::BAD_REQUEST, "InvalidRequest"), | ||
ReadBackError::RepoMismatch(_) => (StatusCode::BAD_REQUEST, "RepoMismatch"), | ||
ReadBackError::UnknownCollection(_) => (StatusCode::BAD_REQUEST, "UnknownCollection"), | ||
ReadBackError::Pds { .. } => (StatusCode::BAD_GATEWAY, "UpstreamFailure"), | ||
ReadBackError::Refused { .. } => (StatusCode::BAD_REQUEST, "InvalidRequest"), | ||
ReadBackError::Index(_) => (StatusCode::INTERNAL_SERVER_ERROR, "InternalServerError"), | ||
}; | ||
Failure::new(status, error, message) | ||
} | ||
} | ||
| ||
/// Reads each of `uris`, records in `did`'s repo, back from `pds`, that | ||
/// repo's PDS, and puts it in `index` as the PDS has it, or deletes it | ||
/// from `index` if the PDS has no such record. `did` is tracked from then | ||
/// on, so that Jetstream keeps what it writes next. | ||
/// | ||
/// A URI outside `did`'s repo, or of a collection the index does not keep, | ||
/// refuses the whole call before anything is read. The records are read | ||
/// at once, and each one read is applied whatever became of the others, | ||
/// so a call that fails part way can be made again as it was. A URI named | ||
/// twice is read once. | ||
pub async fn read_back( | ||
ctx: &Ctx, | ||
index: &Index, | ||
did: &str, | ||
pds: &str, | ||
uris: &[String], | ||
) -> Result<ReadBack, ReadBackError> { | ||
let mut named: Vec<(&str, &str, &str)> = Vec::new(); | ||
for uri in uris { | ||
let Some((repo, collection, rkey)) = record_uri(uri) else { | ||
return Err(ReadBackError::NotARecord(uri.clone())); | ||
}; | ||
if repo != did { | ||
return Err(ReadBackError::RepoMismatch(uri.clone())); | ||
} | ||
if records::table_of(collection).is_none() { | ||
return Err(ReadBackError::UnknownCollection(uri.clone())); | ||
} | ||
if !named.iter().any(|(u, _, _)| u == uri) { | ||
named.push((uri, collection, rkey)); | ||
} | ||
} | ||
let pds = Xrpc::new(ctx.http.clone(), pds); | ||
let read = named | ||
.iter() | ||
.map(|&(_, collection, rkey)| get_record(&pds, did, collection, rkey)); | ||
let read = futures::future::join_all(read).await; | ||
index.track(did).map_err(ReadBackError::Index)?; | ||
let mut done = ReadBack::default(); | ||
let mut failed = None; | ||
for ((uri, _, _), read) in named.into_iter().zip(read) { | ||
let applied = match read { | ||
Ok(Some((cid, record))) => match index.put(uri, &cid, &record) { | ||
Ok(_) => Ok(&mut done.indexed), | ||
Err(PutError::Refused(why)) => Err(ReadBackError::Refused { | ||
uri: uri.to_string(), | ||
why: why.to_string(), | ||
}), | ||
Err(PutError::Db(e)) => Err(ReadBackError::Index(e)), | ||
}, | ||
Ok(None) => match index.delete(uri) { | ||
Ok(_) => Ok(&mut done.not_found), | ||
Err(e) => Err(ReadBackError::Index(e)), | ||
}, | ||
Err(error) => Err(ReadBackError::Pds { | ||
uri: uri.to_string(), | ||
error, | ||
}), | ||
}; | ||
match applied { | ||
Ok(into) => into.push(uri.to_string()), | ||
Err(e) => failed = failed.or(Some(e)), | ||
} | ||
} | ||
match failed { | ||
Some(e) => Err(e), | ||
None => Ok(done), | ||
} | ||
} | ||
| ||
/// The record at `did`/`collection`/`rkey` as its PDS has it, with its | ||
/// CID; none if the PDS says it has no such record. | ||
async fn get_record( | ||
pds: &Xrpc, | ||
did: &str, | ||
collection: &str, | ||
rkey: &str, | ||
) -> anyhow::Result<Option<(String, Value)>> { | ||
#[derive(Deserialize)] | ||
struct Out { | ||
cid: String, | ||
value: Value, | ||
} | ||
let params = [("repo", did), ("collection", collection), ("rkey", rkey)]; | ||
let out: Out = match pds.query("com.atproto.repo.getRecord", ¶ms, None).await { | ||
Ok(out) => out, | ||
Err(e) if XrpcError::of(&e).is_some_and(|e| e.error == "RecordNotFound") => { | ||
return Ok(None); | ||
} | ||
Err(e) => return Err(e), | ||
}; | ||
Ok(Some((out.cid, out.value))) | ||
} | ||
| ||
#[derive(Deserialize)] | ||
struct Input { | ||
uris: Vec<String>, | ||
} | ||
| ||
/// `social.grain.sync.indexRecords`: the caller's service token checked, | ||
/// then [`read_back`] from the PDS their DID document names. It answers | ||
/// once every record is indexed, so a read after it sees the write. | ||
pub async fn index_records( | ||
State(app): State<Arc<App>>, | ||
headers: HeaderMap, | ||
body: Bytes, | ||
) -> Result<Json<ReadBack>, Failure> { | ||
let (Some(index), Some(aud)) = (&app.index, &app.did) else { | ||
let why = "this server keeps no index, or has no DID to take service tokens for"; | ||
return Err(Failure::new( | ||
StatusCode::NOT_IMPLEMENTED, | ||
"MethodNotImplemented", | ||
why, | ||
)); | ||
}; | ||
let token = headers | ||
.get(header::AUTHORIZATION) | ||
.and_then(|v| v.to_str().ok()) | ||
.and_then(|v| v.strip_prefix("Bearer ")) | ||
.ok_or_else(|| { | ||
let why = "a service token is required"; | ||
Failure::new(StatusCode::UNAUTHORIZED, "AuthenticationRequired", why) | ||
})?; | ||
let did = grain::auth::verify(&app.ctx, token, aud, LXM) | ||
.await | ||
.map_err(|e| Failure::new(StatusCode::UNAUTHORIZED, "InvalidToken", format!("{e:#}")))?; | ||
let input: Input = serde_json::from_slice(&body) | ||
.map_err(|e| Failure::invalid(format!("the input is not {{uris}}: {e}")))?; | ||
if input.uris.is_empty() || input.uris.len() > MAX_URIS { | ||
let why = format!( | ||
"uris names 1 to {MAX_URIS} records, not {}", | ||
input.uris.len() | ||
); | ||
return Err(Failure::invalid(why)); | ||
} | ||
let pds = grain::identity::resolve_pds(&app.ctx, &did) | ||
.await | ||
.map_err(|e| Failure::new(StatusCode::BAD_GATEWAY, "UpstreamFailure", format!("{e:#}")))?; | ||
let done = read_back(&app.ctx, index, &did, &pds, &input.uris).await?; | ||
tracing::info!(%did, indexed = done.indexed.len(), not_found = done.not_found.len(), | ||
"indexRecords"); | ||
Ok(Json(done)) | ||
} | ||
| ||
#[cfg(test)] | ||
mod tests { | ||
use super::*; | ||
| ||
#[test] | ||
fn each_failure_answers_as_xrpc_does() { | ||
let uri = || "at://did:plc:a/social.grain.gallery/3l3qo2vuowo2a".to_string(); | ||
let cases = [ | ||
(ReadBackError::NotARecord(uri()), 400, "InvalidRequest"), | ||
(ReadBackError::RepoMismatch(uri()), 400, "RepoMismatch"), | ||
( | ||
ReadBackError::UnknownCollection(uri()), | ||
400, | ||
"UnknownCollection", | ||
), | ||
( | ||
ReadBackError::Pds { | ||
uri: uri(), | ||
error: anyhow::anyhow!("down"), | ||
}, | ||
502, | ||
"UpstreamFailure", | ||
), | ||
( | ||
ReadBackError::Refused { | ||
uri: uri(), | ||
why: "title: required".into(), | ||
}, | ||
400, | ||
"InvalidRequest", | ||
), | ||
( | ||
ReadBackError::Index(rusqlite::Error::InvalidQuery), | ||
500, | ||
"InternalServerError", | ||
), | ||
]; | ||
for (e, status, error) in cases { | ||
let said = e.to_string(); | ||
let failure = Failure::from(e); | ||
assert_eq!(failure.status.as_u16(), status, "{said}"); | ||
assert_eq!(failure.error, error); | ||
assert_eq!(failure.message, said); | ||
} | ||
} | ||
} | ||
crates/grain-serve/src/xrpc.rsnew+39−0
| @@ -0,0 +1,39 @@ | ||
//! XRPC failures as grain-serve answers them: a status and atproto's | ||
//! `{error, message}`. | ||
| ||
use axum::Json; | ||
use axum::http::StatusCode; | ||
use axum::response::{IntoResponse, Response}; | ||
use serde_json::json; | ||
| ||
/// A call refused, or one that failed on the way. | ||
#[derive(Debug, Clone, PartialEq, Eq)] | ||
pub struct Failure { | ||
pub status: StatusCode, | ||
/// The error's name: one the method's lexicon declares, or one of | ||
/// XRPC's own, such as `InvalidRequest`. | ||
pub error: &'static str, | ||
pub message: String, | ||
} | ||
| ||
impl Failure { | ||
pub fn new(status: StatusCode, error: &'static str, message: impl Into<String>) -> Failure { | ||
Failure { | ||
status, | ||
error, | ||
message: message.into(), | ||
} | ||
} | ||
| ||
/// A request that does not say what it must: 400 `InvalidRequest`. | ||
pub fn invalid(message: impl Into<String>) -> Failure { | ||
Failure::new(StatusCode::BAD_REQUEST, "InvalidRequest", message) | ||
} | ||
} | ||
| ||
impl IntoResponse for Failure { | ||
fn into_response(self) -> Response { | ||
let body = json!({ "error": self.error, "message": self.message }); | ||
(self.status, Json(body)).into_response() | ||
} | ||
} | ||
crates/grain-serve/tests/common/mod.rs+19−2
| @@ -12,11 +12,18 @@ pub struct Served { | ||
http: reqwest::Client, | ||
} | ||
| ||
/// grain-serve, serving on a free port until the test's runtime ends. | ||
/// grain-serve with no index and no DID, serving on a free port until the | ||
/// test's runtime ends. | ||
pub async fn served() -> Served { | ||
let ctx = grain::env::Ctx::new(grain::env::Env::from_vars(|_| None)); | ||
served_app(grain_serve::App::bare(ctx)).await | ||
} | ||
| ||
/// `app`, serving on a free port until the test's runtime ends. | ||
pub async fn served_app(app: grain_serve::App) -> Served { | ||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); | ||
let base = format!("http://{}", listener.local_addr().unwrap()); | ||
tokio::spawn(grain_serve::serve(listener)); | ||
tokio::spawn(grain_serve::serve(listener, std::sync::Arc::new(app))); | ||
Served { | ||
base, | ||
http: reqwest::Client::new(), | ||
| @@ -36,6 +43,16 @@ impl Served { | ||
self.answer(path, res).await | ||
} | ||
| ||
/// A procedure called with `token`, a service token, as its bearer. | ||
pub async fn post_as(&self, path: &str, token: &str, body: &Value) -> (u16, Value) { | ||
let res = self | ||
.http | ||
.post(format!("{}{path}", self.base)) | ||
.bearer_auth(token) | ||
.json(body); | ||
self.answer(path, res).await | ||
} | ||
| ||
async fn answer(&self, path: &str, req: reqwest::RequestBuilder) -> (u16, Value) { | ||
let res = req.send().await.unwrap(); | ||
let status = res.status().as_u16(); | ||
crates/grain-serve/tests/index_records.rsnew+349−0
| @@ -0,0 +1,349 @@ | ||
//! `social.grain.sync.indexRecords` against the reference PDS: an account | ||
//! writes grain's records to its own PDS, asks that PDS for a service token | ||
//! for grain-serve, and names the records, which grain-serve reads back | ||
//! from the PDS into its index. | ||
//! | ||
//! The reference PDS resolves no lexicon over the network: it validates a | ||
//! record only against the schemas it bundles, and keeps one of a `$type` | ||
//! it has none for with `validationStatus: "unknown"` when `validate` is | ||
//! unset, as the apps leave it. It bundles copies of `social.grain.gallery`, | ||
//! `social.grain.photo` and `social.grain.favorite`, which it holds those | ||
//! to; grain's other collections it keeps unchecked. So the tests write as | ||
//! the apps do, and nothing reaches for grain.social. | ||
| ||
#[path = "../../grain/tests/common/mod.rs"] | ||
mod net; | ||
| ||
mod common; | ||
| ||
use grain::auth; | ||
use grain::env::Ctx; | ||
use grain::session::{self, Session}; | ||
use grain::xrpc::Xrpc; | ||
use grain_serve::App; | ||
use grain_serve::index::Index; | ||
use grain_serve::index::records::{Follow, Gallery}; | ||
use grain_serve::sync::{LXM, ReadBackError, read_back}; | ||
use serde_json::{Value, json}; | ||
use std::sync::Arc; | ||
| ||
const PATH: &str = "/xrpc/social.grain.sync.indexRecords"; | ||
/// The DID the server takes service tokens for. Nothing resolves it: a | ||
/// PDS signs a token for any `aud`. | ||
const AUD: &str = "did:web:grain.test"; | ||
const NOW: &str = "2026-10-31T12:00:00.000Z"; | ||
| ||
struct Caller { | ||
ctx: Ctx, | ||
me: Session, | ||
index: Arc<Index>, | ||
served: common::Served, | ||
_dir: tempfile::TempDir, | ||
} | ||
| ||
/// A fresh account on the reference PDS, logged in, and grain-serve with | ||
/// an index of its own, taking tokens for [`AUD`]. | ||
async fn caller() -> Caller { | ||
let dir = tempfile::tempdir().unwrap(); | ||
let ctx = net::ctx(dir.path(), &[]); | ||
let index = Arc::new(Index::memory().unwrap()); | ||
let served = common::served_app(App { | ||
ctx: ctx.clone(), | ||
index: Some(index.clone()), | ||
did: Some(AUD.into()), | ||
}) | ||
.await; | ||
let account = net::account().await; | ||
let me = session::create(&ctx, &account.did, &account.password) | ||
.await | ||
.unwrap(); | ||
Caller { | ||
ctx, | ||
me, | ||
index, | ||
served, | ||
_dir: dir, | ||
} | ||
} | ||
| ||
impl Caller { | ||
fn pds(&self) -> Xrpc { | ||
Xrpc::new(self.ctx.http.clone(), &self.me.service) | ||
} | ||
| ||
/// Writes `record` to `collection` in the caller's repo, as the apps | ||
/// do: its URI and CID. | ||
async fn write(&self, collection: &str, record: Value) -> (String, String) { | ||
let input = json!({"repo": self.me.did, "collection": collection, "record": record}); | ||
self.written("com.atproto.repo.createRecord", input).await | ||
} | ||
| ||
/// Writes `record` over the one at `uri`. | ||
async fn rewrite(&self, uri: &str, record: Value) -> (String, String) { | ||
let (collection, rkey) = parts(uri); | ||
let input = | ||
json!({"repo": self.me.did, "collection": collection, "rkey": rkey, "record": record}); | ||
self.written("com.atproto.repo.putRecord", input).await | ||
} | ||
| ||
async fn written(&self, nsid: &str, input: Value) -> (String, String) { | ||
let out: Value = self | ||
.pds() | ||
.procedure(nsid, Some(&input), Some(&self.me.auth())) | ||
.await | ||
.unwrap(); | ||
let field = |name: &str| out[name].as_str().unwrap().to_string(); | ||
(field("uri"), field("cid")) | ||
} | ||
| ||
async fn delete(&self, uri: &str) { | ||
let (collection, rkey) = parts(uri); | ||
let input = json!({"repo": self.me.did, "collection": collection, "rkey": rkey}); | ||
let _: Value = self | ||
.pds() | ||
.procedure( | ||
"com.atproto.repo.deleteRecord", | ||
Some(&input), | ||
Some(&self.me.auth()), | ||
) | ||
.await | ||
.unwrap(); | ||
} | ||
| ||
/// A token from the caller's PDS for `lxm` on `aud`. | ||
async fn token_for(&self, aud: &str, lxm: &str) -> String { | ||
auth::service_token(&self.ctx, &self.me, aud, lxm) | ||
.await | ||
.unwrap() | ||
} | ||
| ||
async fn token(&self) -> String { | ||
self.token_for(AUD, LXM).await | ||
} | ||
| ||
/// indexRecords on `uris`, with a token as the apps ask for one. | ||
async fn index_records(&self, uris: &[&str]) -> (u16, Value) { | ||
let token = self.token().await; | ||
self.served | ||
.post_as(PATH, &token, &json!({ "uris": uris })) | ||
.await | ||
} | ||
} | ||
| ||
fn parts(uri: &str) -> (&str, &str) { | ||
let mut parts = uri.rsplitn(3, '/'); | ||
let rkey = parts.next().unwrap(); | ||
(parts.next().unwrap(), rkey) | ||
} | ||
| ||
fn gallery(title: &str) -> Value { | ||
json!({"$type": "social.grain.gallery", "title": title, "createdAt": NOW}) | ||
} | ||
| ||
#[tokio::test] | ||
async fn records_written_are_read_back_into_the_index() { | ||
let c = caller().await; | ||
let (gallery_uri, cid) = c.write("social.grain.gallery", gallery("Fog")).await; | ||
let follow = | ||
json!({"$type": "social.grain.graph.follow", "subject": "did:plc:bob", "createdAt": NOW}); | ||
let (follow_uri, _) = c.write("social.grain.graph.follow", follow).await; | ||
assert!(!c.index.tracks(&c.me.did)); | ||
| ||
let (status, body) = c.index_records(&[&gallery_uri, &follow_uri]).await; | ||
assert_eq!(status, 200, "{body}"); | ||
assert_eq!( | ||
body, | ||
json!({"indexed": [gallery_uri, follow_uri], "notFound": []}) | ||
); | ||
let held = c.index.get::<Gallery>(&gallery_uri).unwrap().unwrap(); | ||
assert_eq!( | ||
(held.cid.as_str(), held.did.as_str()), | ||
(cid.as_str(), c.me.did.as_str()) | ||
); | ||
assert_eq!(held.record.title, "Fog"); | ||
let follows = c.index.get::<Follow>(&follow_uri).unwrap().unwrap(); | ||
assert_eq!(follows.record.subject, "did:plc:bob"); | ||
// The caller is tracked, so Jetstream keeps what they write next. | ||
assert!(c.index.tracks(&c.me.did)); | ||
| ||
// Again, and named twice: the same record by its CID changes nothing. | ||
let (status, again) = c.index_records(&[&gallery_uri, &gallery_uri]).await; | ||
assert_eq!(status, 200, "{again}"); | ||
assert_eq!(again, json!({"indexed": [gallery_uri], "notFound": []})); | ||
let still = c.index.get::<Gallery>(&gallery_uri).unwrap().unwrap(); | ||
assert_eq!(still, held); | ||
| ||
// Edited: the PDS's new version replaces the one held. | ||
let (_, edited) = c.rewrite(&gallery_uri, gallery("Fog, later")).await; | ||
let (status, body) = c.index_records(&[&gallery_uri]).await; | ||
assert_eq!(status, 200, "{body}"); | ||
let now = c.index.get::<Gallery>(&gallery_uri).unwrap().unwrap(); | ||
assert_eq!( | ||
(now.cid.as_str(), now.record.title.as_str()), | ||
(edited.as_str(), "Fog, later") | ||
); | ||
assert_eq!(now.indexed_at, held.indexed_at); | ||
} | ||
| ||
#[tokio::test] | ||
async fn records_gone_from_the_pds_are_deleted() { | ||
let c = caller().await; | ||
let (uri, _) = c.write("social.grain.gallery", gallery("Gone")).await; | ||
let (status, _) = c.index_records(&[&uri]).await; | ||
assert_eq!(status, 200); | ||
assert!(c.index.get::<Gallery>(&uri).unwrap().is_some()); | ||
| ||
c.delete(&uri).await; | ||
let never = format!("at://{}/social.grain.gallery/3l3qo2vuowo2a", c.me.did); | ||
let (status, body) = c.index_records(&[&uri, &never]).await; | ||
assert_eq!(status, 200, "{body}"); | ||
assert_eq!(body, json!({"indexed": [], "notFound": [uri, never]})); | ||
assert!(c.index.get::<Gallery>(&uri).unwrap().is_none()); | ||
// Gone is gone however often it is said. | ||
let (status, again) = c.index_records(&[&uri]).await; | ||
assert_eq!( | ||
(status, again), | ||
(200, json!({"indexed": [], "notFound": [uri]})) | ||
); | ||
} | ||
| ||
#[tokio::test] | ||
async fn uris_outside_the_callers_repo_or_the_index_are_refused() { | ||
let c = caller().await; | ||
let (mine, _) = c.write("social.grain.gallery", gallery("Mine")).await; | ||
let theirs = "at://did:plc:someoneelse/social.grain.gallery/3l3qo2vuowo2a"; | ||
let like = format!("at://{}/app.bsky.feed.like/3l3qo2vuowo2a", c.me.did); | ||
let no_key = format!("at://{}/social.grain.gallery", c.me.did); | ||
for (uris, error) in [ | ||
(vec![mine.as_str(), theirs], "RepoMismatch"), | ||
(vec![mine.as_str(), like.as_str()], "UnknownCollection"), | ||
(vec![no_key.as_str()], "InvalidRequest"), | ||
] { | ||
let (status, body) = c.index_records(&uris).await; | ||
assert_eq!( | ||
(status, body["error"].as_str()), | ||
(400, Some(error)), | ||
"{body}" | ||
); | ||
} | ||
// Refused before anything was read, the caller's own record too. | ||
assert!(c.index.get::<Gallery>(&mine).unwrap().is_none()); | ||
assert!(!c.index.tracks(&c.me.did)); | ||
| ||
let token = c.token().await; | ||
let many: Vec<String> = (0..26).map(|_| mine.clone()).collect(); | ||
for input in [ | ||
json!({"uris": []}), | ||
json!({"uris": many}), | ||
json!({"uri": mine}), | ||
json!("not an object"), | ||
] { | ||
let (status, body) = c.served.post_as(PATH, &token, &input).await; | ||
assert_eq!( | ||
(status, body["error"].as_str()), | ||
(400, Some("InvalidRequest")), | ||
"{input}: {body}" | ||
); | ||
} | ||
} | ||
| ||
#[tokio::test] | ||
async fn a_record_that_does_not_fit_its_lexicon_is_not_indexed() { | ||
let c = caller().await; | ||
// A follow of no one: the reference PDS bundles no schema for follows, | ||
// so it keeps one, as a PDS that checks nothing would. | ||
let follow = json!({"$type": "social.grain.graph.follow", "createdAt": NOW}); | ||
let (uri, _) = c.write("social.grain.graph.follow", follow).await; | ||
let (status, body) = c.index_records(&[&uri]).await; | ||
assert_eq!( | ||
(status, body["error"].as_str()), | ||
(400, Some("InvalidRequest")), | ||
"{body}" | ||
); | ||
assert!( | ||
body["message"] | ||
.as_str() | ||
.unwrap() | ||
.contains("does not fit its lexicon") | ||
); | ||
assert!(c.index.get::<Follow>(&uri).unwrap().is_none()); | ||
} | ||
| ||
#[tokio::test] | ||
async fn only_a_service_token_for_this_method_on_this_server_lets_a_call_in() { | ||
let c = caller().await; | ||
let (uri, _) = c.write("social.grain.gallery", gallery("Locked")).await; | ||
let input = json!({ "uris": [uri] }); | ||
| ||
let (status, body) = c.served.post(PATH, &input).await; | ||
assert_eq!( | ||
(status, body["error"].as_str()), | ||
(401, Some("AuthenticationRequired")) | ||
); | ||
| ||
let other_server = c.token_for("did:web:other.test", LXM).await; | ||
let other_method = c.token_for(AUD, "social.grain.sync.other").await; | ||
// Signed by a key that is not the account's. | ||
let stranger = grain::crypto::P256Key::generate().sign_jwt( | ||
&json!({"typ": "JWT"}), | ||
&json!({"iss": c.me.did, "aud": AUD, "lxm": LXM, "exp": grain::keys::now_secs() + 60}), | ||
); | ||
// Good for two seconds. | ||
let exp = grain::keys::now_secs() + 2; | ||
let expired = auth::lasting_token(&c.ctx, &c.me, AUD, LXM, exp) | ||
.await | ||
.unwrap(); | ||
while grain::keys::now_secs() <= exp { | ||
tokio::time::sleep(std::time::Duration::from_millis(100)).await; | ||
} | ||
for token in [ | ||
other_server, | ||
other_method, | ||
stranger, | ||
expired, | ||
"not.a.token".into(), | ||
] { | ||
let (status, body) = c.served.post_as(PATH, &token, &input).await; | ||
assert_eq!( | ||
(status, body["error"].as_str()), | ||
(401, Some("InvalidToken")), | ||
"{body}" | ||
); | ||
} | ||
assert!(c.index.get::<Gallery>(&uri).unwrap().is_none()); | ||
} | ||
| ||
#[tokio::test] | ||
async fn a_server_with_no_index_or_did_does_not_serve_it() { | ||
let s = common::served().await; | ||
let (status, body) = s | ||
.post_as( | ||
PATH, | ||
"token", | ||
&json!({"uris": ["at://did:plc:a/social.grain.gallery/3l3qo2vuowo2a"]}), | ||
) | ||
.await; | ||
assert_eq!( | ||
(status, body["error"].as_str()), | ||
(501, Some("MethodNotImplemented")) | ||
); | ||
} | ||
| ||
#[tokio::test] | ||
async fn a_pds_that_does_not_answer_fails_the_read_back() { | ||
let c = caller().await; | ||
let (uri, _) = c.write("social.grain.gallery", gallery("Elsewhere")).await; | ||
let nowhere = "http://127.0.0.1:1"; | ||
let failed = read_back( | ||
&c.ctx, | ||
&c.index, | ||
&c.me.did, | ||
nowhere, | ||
std::slice::from_ref(&uri), | ||
) | ||
.await; | ||
match failed { | ||
Err(ReadBackError::Pds { uri: failed, .. }) => assert_eq!(failed, uri), | ||
other => panic!("{other:?}"), | ||
} | ||
} | ||
crates/grain-serve/Cargo.toml+1−0
| @@ -10,6 +10,7 @@ rust-version.workspace = true | ||
grain.workspace = true | ||
anyhow.workspace = true | ||
axum.workspace = true | ||
futures.workspace = true | ||
rusqlite.workspace = true | ||
serde.workspace = true | ||
serde_json.workspace = true | ||
Cargo.lock+1−0
| @@ -598,6 +598,7 @@ version = "0.1.0" | ||
dependencies = [ | ||
"anyhow", | ||
"axum", | ||
"futures", | ||
"grain", | ||
"reqwest", | ||
"rusqlite", | ||