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

grain.social/grain

photography-focused photo sharing platform

grain.social
1 collaborator
0
2

Seven reviewed pulls land on feature/rust together, checked once

Merged chadtmiller.com asked to merge into feature/rust from grain.social/grain:batch/one

Only what this pull adds. It is built on grain runs on a Rust server modelled on knotless, without hatk.

.knotless/workflows/harness.yml+38−3
@@ -5,8 +5,14 @@
# grain-serve's tests received is as its lexicon says (harness/answers.mjs),
# and that the check would name a wrong one (harness/answers-test.sh).
#
# Node, the harness's packages, cargo-llvm-cov and the build are kept
# between runs; each is made again only when what it is made from changes.
# The test binaries named `spaces` run against the reference PDS on the
# permissioned spaces alpha, built from an atproto commit, as knotless's
# tests are: npm's alpha builds name their dependencies by workspace, so
# they do not install.
#
# Node, the harness's packages, the alpha's build, cargo-llvm-cov and the
# build are kept between runs; each is made again only when what it is
# made from changes.
image: rust:1.98
runners: [runner.knotless.dev]
on:
@@ -19,11 +25,16 @@ cache:
/usr/local/rustup,
/opt/node,
/opt/tools,
/opt/atproto,
harness/node_modules,
]
env:
NODE_VERSION: 24.22.1
LLVM_COV_VERSION: 0.9.1
# The spaces alpha the tests of spaces run against, as knotless's do.
ATPROTO_COMMIT: 11c5d98cd50261c5e1201fedc5c6c05a7b8d1ccf
# Bumped when the steps that build the alpha change, to build it again.
ALPHA_BUILD: "1"
steps:
- name: node
run: |
@@ -38,6 +49,30 @@ steps:
[ "$(cat node_modules/.lock 2>/dev/null)" = "$lock" ] && exit 0
PATH=/opt/node/bin:$PATH npm ci --no-audit --no-fund
echo "$lock" > node_modules/.lock
- name: spaces alpha
run: |
[ "$(cat /opt/atproto/.built 2>/dev/null)" = "$ATPROTO_COMMIT $ALPHA_BUILD" ] && exit 0
export PATH=/opt/node/bin:$PATH COREPACK_ENABLE_DOWNLOAD_PROMPT=0 COREPACK_HOME=/opt/atproto/.corepack
mkdir -p /opt/atproto && find /opt/atproto -mindepth 1 -delete
cd /opt/atproto
git init -q . && git fetch -q --depth 1 https://github.com/bluesky-social/atproto.git "$ATPROTO_COMMIT" && git checkout -q FETCH_HEAD
mkdir .bin && corepack enable --install-directory /opt/atproto/.bin
export PATH=/opt/atproto/.bin:$PATH
pnpm install --frozen-lockfile --ignore-scripts
pnpm run build:tooling --force
pnpm --filter '@atproto/dev-env^...' --filter @atproto/dev-env run --if-present prebuild
pnpm exec tsc --build packages/dev-env/tsconfig.build.json
# The PDS serves the OAuth sign-in pages from this bundle.
pnpm --filter @atproto/oauth-provider-ui run build:ui
echo "$ATPROTO_COMMIT $ALPHA_BUILD" > /opt/atproto/.built
- name: sqlite
run: |
# The one install script the alpha's PDS needs, which the install
# above skips: SQLite's native module, as better-sqlite3 builds it.
export PATH=/opt/node/bin:$PATH
cd "$(dirname "$(node -p "require.resolve('better-sqlite3/package.json', { paths: ['/opt/atproto/packages/pds'] })")")"
[ -f build/Release/better_sqlite3.node ] && exit 0
npm run install
- name: tools
run: |
rustup component add llvm-tools-preview
@@ -47,7 +82,7 @@ steps:
echo "$LLVM_COV_VERSION" > /opt/tools/.llvm-cov
- name: test
run: |
export PATH=/opt/node/bin:/opt/tools:$PATH
export PATH=/opt/node/bin:/opt/tools:$PATH GRAIN_DEV_ENV=/opt/atproto/packages/dev-env
COV_JOBS=$(( $(nproc) / 2 )) COV_HARNESSES=2 COV_TOOLCHAIN=$(rustup show active-toolchain | cut -d' ' -f1) \
node harness/cov.mjs
- name: answers
crates/grain/src/auth.rs+70−0
@@ -82,6 +82,46 @@ async fn signed_by(ctx: &Ctx, token: &str, did: &str) -> Result<()> {
Ok(())
}
/// The type a space delegation token names in its header.
const DELEGATION_TYP: &str = "atproto-space-delegation+jwt";
/// A delegation token, as a member's PDS signs one for a space's
/// authority (`com.atproto.space.getDelegationToken`).
#[derive(Deserialize, Debug, Clone)]
pub struct Delegation {
/// The account it names.
#[serde(rename = "iss")]
pub did: String,
/// The space it is for.
#[serde(rename = "sub")]
pub space: String,
/// What spends it: an authority takes each once.
pub jti: String,
/// When it stops, in seconds since the epoch.
pub exp: u64,
aud: String,
}
/// Checks a delegation token, for a space, signed by the account it names
/// with the key its DID document names ([`signed_by`]), and returns what
/// it holds. It names a space's authority, not whoever holds it: anyone
/// handed one may exchange it, so it proves who sent it only together with
/// being spent once.
pub async fn verify_delegation(ctx: &Ctx, token: &str) -> Result<Delegation> {
let (header, claims) = crypto::jwt_parts(token)?;
if header["typ"].as_str() != Some(DELEGATION_TYP) {
bail!("not a delegation token: {}", header["typ"]);
}
let found: Delegation = serde_json::from_value(claims)?;
let authority = found.space.strip_prefix("at://").unwrap_or_default();
let authority = authority.split('/').next().unwrap_or_default();
if found.aud != format!("{authority}#atproto_pds") {
bail!("a token for {}, not {}'s authority", found.aud, found.space);
}
signed_by(ctx, token, &found.did).await?;
Ok(found)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -211,4 +251,34 @@ mod tests {
let nowhere = sign(&key, claims("did:web:127.0.0.1%3A1"));
assert!(verify(&ctx, &nowhere, aud, lxm).await.is_err());
}
/// An unsigned JWT of `header` and `claims`: what is refused before any
/// signature is checked.
fn unsigned(header: Value, claims: Value) -> String {
use base64::Engine;
let part =
|v: &Value| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(v.to_string());
format!("{}.{}.x", part(&header), part(&claims))
}
#[tokio::test]
async fn a_delegation_token_names_its_type_and_the_spaces_authority() {
let ctx = Ctx::new(Env::from_vars(|_| None));
let claims = |aud: &str| {
json!({"iss": "did:plc:member", "sub": "at://did:plc:owner/space/social.grain.group/self",
"aud": aud, "jti": "1", "exp": 1})
};
let typ = |typ: &str| json!({"alg": "ES256", "typ": typ});
let service = unsigned(typ("JWT"), claims("did:plc:owner#atproto_pds"));
let why = verify_delegation(&ctx, &service).await.unwrap_err();
assert!(
why.to_string().starts_with("not a delegation token"),
"{why}"
);
let elsewhere = unsigned(typ(DELEGATION_TYP), claims("did:plc:other#atproto_pds"));
let why = verify_delegation(&ctx, &elsewhere).await.unwrap_err();
assert!(why.to_string().contains("not at://did:plc:owner/"), "{why}");
let bare = unsigned(typ(DELEGATION_TYP), json!({"iss": "did:plc:member"}));
assert!(verify_delegation(&ctx, &bare).await.is_err());
}
}
crates/grain/src/cli.rs+1−1
@@ -25,7 +25,7 @@ pub const USAGE: &str = "usage: grain <command>
violation, other, copyright, nudity,
child-safety, or a reasonType
as an admin, an account grain's appview names in GRAIN_ADMINS:
as an admin, an account grain's appview names in GRAIN_ADMIN_DIDS:
admin reports [--status open|resolved|dismissed] [--label <label>]
[--limit <n>] [--cursor <cursor>]
crates/grain/src/identity.rs+2−2
@@ -40,8 +40,8 @@ impl std::fmt::Display for NoSuchHandle {
impl std::error::Error for NoSuchHandle {}
/// 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`, and grain-serve's of `GRAIN_PUBLIC_URL`.
/// `http://` URL: the `did:web` of its host, as knotless-serve's and
/// grain-serve's are of their `--public-url`.
/// `https://grain.social` is `did:web:grain.social`.
pub fn service_did(public_url: &str) -> Result<String> {
let url = public_url.trim_end_matches('/');
crates/grain/src/lib.rs+1−0
@@ -20,6 +20,7 @@ pub mod moderation;
pub mod oauth;
pub mod repo;
pub mod session;
pub mod space;
pub mod store;
pub mod telemetry;
pub mod xrpc;
crates/grain/src/moderation.rs+1−1
@@ -7,7 +7,7 @@
//! naming the appview `GRAIN_APPVIEW` by its DID: `GRAIN_APPVIEW_DID`, or
//! the `did:web` of its host ([`identity::service_did`]).
//! The appview lets an admin's calls in only from the accounts it names
//! in `GRAIN_ADMINS`.
//! in `GRAIN_ADMIN_DIDS`.
use crate::auth;
use crate::env::Ctx;
crates/grain/src/oauth.rs+46−3
@@ -34,9 +34,9 @@ const CALLBACK: &str = "/callback";
/// gallery to Bluesky, which `putRecord` does so that a publish resumed
/// overwrites its own post, and so asks to update as well as create.
///
/// Private galleries and groups ask for space and `rpc:` scopes beside
/// these, and only of a PDS that serves spaces: a login that needs them
/// adds them to what it passes to [`authorize`].
/// Private galleries and groups ask for [`SPACE_SCOPE`] beside these, and
/// only of a PDS that serves spaces: a login that needs them adds them to
/// what it passes to [`authorize`].
pub const SCOPE: &str = "atproto \
blob:image/* \
repo:social.grain.gallery \
@@ -51,6 +51,44 @@ repo:social.grain.story \
repo:social.grain.graph.block \
repo:app.bsky.feed.post?action=create&action=update";
/// What a login asks for beside [`SCOPE`] of a PDS that serves spaces: to
/// make private galleries, and read and write them (each is a space of
/// `social.grain.privateGallery` the author makes and alone writes); to
/// read a group's pool and write galleries, favorites and comments to it
/// (a space of `social.grain.group` the group makes, hence any
/// authority); to offer a gallery to a group and, acting as one, answer
/// it; to ask a group host to join, leave, found a group and open its
/// pool, by service auth for any host; and to read a group's member list
/// and keep its own acceptance there.
pub const SPACE_SCOPE: &str = "\
space:social.grain.privateGallery?authority=*&skey=*&collection=social.grain.gallery\
&collection=social.grain.gallery.item&collection=social.grain.photo\
&action=read&action=create&action=update&action=delete\
&manage=create&manage=update&manage=delete \
space:social.grain.group?authority=*&skey=*&collection=social.grain.gallery\
&collection=social.grain.gallery.item&collection=social.grain.photo\
&collection=social.grain.favorite&collection=social.grain.comment\
&action=read&action=create&action=update&action=delete \
space:social.grain.group?authority=*&skey=*&action=read \
repo:social.grain.group.submission \
repo:social.grain.group.item \
repo:social.grain.group.decline \
rpc:fyi.opensocial.requestJoin?aud=* \
rpc:fyi.opensocial.leaveGroup?aud=* \
rpc:fyi.opensocial.provisionGroup?aud=* \
rpc:fyi.opensocial.createSpace?aud=* \
space:fyi.opensocial.members?authority=*&skey=*&collection=fyi.opensocial.acceptance\
&action=read&action=create&action=update&action=delete";
/// What a login asks of a PDS: [`SCOPE`], and [`SPACE_SCOPE`] with it of
/// one that serves spaces.
pub fn scope(spaces: bool) -> String {
match spaces {
true => format!("{SCOPE} {SPACE_SCOPE}"),
false => SCOPE.to_string(),
}
}
/// What the command line's loopback client asks for: [`SCOPE`], and to
/// call grain's appview as the account for what it reports and, for an
/// admin, how it moderates ([`crate::moderation::CALLED`]).
@@ -854,6 +892,11 @@ pub(crate) mod tests {
assert!(SCOPE.ends_with(" repo:app.bsky.feed.post?action=create&action=update"));
assert!(!SCOPE.contains("transition:"));
assert!(!SCOPE.contains(" "));
assert_eq!(scope(false), SCOPE);
let spaces = scope(true);
assert!(spaces.starts_with(&format!("{SCOPE} space:social.grain.privateGallery?")));
assert!(!spaces.contains(" ") && !spaces.ends_with(' '));
assert_eq!(spaces.split(' ').count(), SCOPE.split(' ').count() + 11);
// It may call the appview's moderation as the account, as the
// web's client, which asks SCOPE alone, may not.
let loopback = loopback_scope();
crates/grain/src/space.rsnew+838−0
838 lines changed: not loaded until asked for.
crates/grain/tests/common/mod.rs+14−0
@@ -21,6 +21,10 @@ pub struct Account {
pub struct Net {
pub pds: String,
pub plc: String,
/// Whether the PDS is on the permissioned spaces alpha
/// (`GRAIN_DEV_ENV`).
#[serde(default)]
pub spaces: bool,
pub client: ClientTls,
pub accounts: Vec<Account>,
}
@@ -115,6 +119,16 @@ fn start() -> Result<(Net, Child), String> {
Ok((net, child))
}
/// Whether the PDS is on the spaces alpha, saying so when it is not: a
/// test of spaces passes trivially without it.
pub fn spaces() -> bool {
let on = net().spaces;
if !on {
eprintln!("skipped: the PDS is not on the spaces alpha (GRAIN_DEV_ENV)");
}
on
}
static NEXT: Mutex<usize> = Mutex::new(0);
/// A handle no other test binary, in this run or an earlier one on the
crates/grain/tests/spaces.rsnew+208−0
@@ -0,0 +1,208 @@
//! Permissioned spaces against the reference PDS on the spaces alpha
//! (`GRAIN_DEV_ENV`): a space made, members put on it, and its repos read
//! by its members with credentials and refused to everyone else. These
//! pass trivially on a PDS without spaces.
mod common;
use common::*;
use grain::env::Ctx;
use grain::session::{self, Session};
use grain::space::{self, Admin};
use grain::xrpc::Xrpc;
use grain::{auth, keys};
use serde_json::json;
/// A 1x1 PNG.
const PNG: &[u8] = &[
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4,
0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0xf8, 0xcf, 0xc0, 0xf0,
0x1f, 0x00, 0x05, 0x00, 0x01, 0xff, 0x89, 0x99, 0x3d, 0x1d, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45,
0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
];
/// A new account, logged in with its password.
async fn someone(ctx: &Ctx) -> Session {
let a = account().await;
session::create(ctx, &a.handle, &a.password).await.unwrap()
}
#[tokio::test]
async fn a_private_gallery_is_read_by_its_members_alone() {
if !spaces() {
return;
}
let dir = tempfile::tempdir().unwrap();
let ctx = ctx(dir.path(), &[]);
let (owner, reader, outsider) = (
someone(&ctx).await,
someone(&ctx).await,
someone(&ctx).await,
);
let admin = Admin {
ctx: &ctx,
session: &owner,
};
let rkey = keys::tid();
let gallery = admin.create(space::PRIVATE_GALLERY, &rkey).await.unwrap();
assert_eq!(
gallery,
space::uri(&owner.did, space::PRIVATE_GALLERY, &rkey)
);
// The photo's blob is the owner's, uploaded as any other.
let pds = Xrpc::new(ctx.guarded.clone(), &owner.service);
let blob = pds
.upload_blob(PNG.to_vec(), "image/png", &owner.auth())
.await
.unwrap();
let now = keys::now_iso();
let writes = [
space::create(
"social.grain.gallery",
&rkey,
json!({"$type": "social.grain.gallery", "title": "Hidden", "createdAt": now}),
),
space::create(
"social.grain.photo",
&format!("{rkey}-0"),
json!({"$type": "social.grain.photo", "photo": blob,
"aspectRatio": {"width": 1, "height": 1}, "createdAt": now}),
),
];
admin.apply_writes(&gallery, &writes).await.unwrap();
admin
.put_member(&gallery, &reader.did, false)
.await
.unwrap();
let members = admin.members(&gallery).await.unwrap();
assert!(members.iter().any(|m| m.did == reader.did), "{members:?}");
assert!(!members.iter().any(|m| m.did == outsider.did));
// The owner reads their own repo with their session, and finds the
// space among their own.
let own = admin
.records(&gallery, "social.grain.gallery")
.await
.unwrap();
assert_eq!(own[0].rkey, rkey);
assert_eq!(own[0].value["title"], "Hidden");
let listed = space::list_spaces(&ctx, &owner, space::PRIVATE_GALLERY)
.await
.unwrap();
assert!(listed.contains(&gallery), "{listed:?}");
// A member reads it with a credential, blob and all.
let credential = space::credential(&ctx, &reader, &gallery).await.unwrap();
assert!(credential.lasts());
assert_eq!(credential.reader, reader.did);
let photos = space::read_with(&ctx, &credential, &owner.did, "social.grain.photo")
.await
.unwrap();
assert_eq!(photos.len(), 1);
let cid = space::blob_cid(&photos[0].value["photo"])
.unwrap()
.to_string();
let bytes = space::blob_with(&ctx, &credential, &owner.did, &cid)
.await
.unwrap();
assert_eq!(bytes, PNG);
let repos = space::repos_with(&ctx, &credential).await.unwrap();
assert_eq!(repos, std::slice::from_ref(&owner.did));
// Anyone else is refused at the exchange, as if there were no such
// space.
let refused = space::credential(&ctx, &outsider, &gallery)
.await
.err()
.unwrap();
assert!(space::denied(&refused), "{refused:#}");
// A delegation token names its reader to the space's authority, and is
// checked against the reader's key.
let token = space::delegation(&ctx, &outsider, &gallery).await.unwrap();
let found = auth::verify_delegation(&ctx, &token).await.unwrap();
assert_eq!(
(found.did, found.space),
(outsider.did.clone(), gallery.clone())
);
}
#[tokio::test]
async fn a_pools_members_write_their_own_repos_and_read_each_others() {
if !spaces() {
return;
}
let dir = tempfile::tempdir().unwrap();
let ctx = ctx(dir.path(), &[]);
let (group, member, outsider) = (
someone(&ctx).await,
someone(&ctx).await,
someone(&ctx).await,
);
let pool = Admin {
ctx: &ctx,
session: &group,
}
.create(space::POOL, "self")
.await
.unwrap();
assert_eq!(pool, space::pool(&group.did));
let admin = Admin {
ctx: &ctx,
session: &group,
};
admin.put_member(&pool, &member.did, true).await.unwrap();
// The member writes a comment into their own repo there, then takes it
// back.
let mine = Admin {
ctx: &ctx,
session: &member,
};
let comment = json!({"$type": "social.grain.comment", "text": "lovely",
"subject": "at://did:plc:x/social.grain.gallery/1", "createdAt": keys::now_iso()});
mine.put_record(&pool, "social.grain.comment", "c1", &comment)
.await
.unwrap();
mine.put_record(&pool, "social.grain.comment", "c2", &comment)
.await
.unwrap();
mine.delete_record(&pool, "social.grain.comment", "c2")
.await
.unwrap();
let held = mine.records(&pool, "social.grain.comment").await.unwrap();
assert_eq!(
held.iter().map(|r| r.rkey.as_str()).collect::<Vec<_>>(),
["c1"]
);
// The group reads its members' repos with a credential of its own, and
// asks its authority which repos the space holds.
let credential = space::credential(&ctx, &group, &pool).await.unwrap();
let repos = space::repos_with(&ctx, &credential).await.unwrap();
assert!(repos.contains(&member.did), "{repos:?}");
let read = space::read_with(&ctx, &credential, &member.did, "social.grain.comment")
.await
.unwrap();
assert_eq!(read[0].value["text"], "lovely");
// An outsider's own PDS keeps what they write there, but the space
// does not take them for a writer, and will not let them read it.
let theirs = Admin {
ctx: &ctx,
session: &outsider,
};
let _ = theirs
.put_record(&pool, "social.grain.comment", "c3", &comment)
.await;
let credential = space::credential(&ctx, &group, &pool).await.unwrap();
let repos = space::repos_with(&ctx, &credential).await.unwrap();
assert!(!repos.contains(&outsider.did), "{repos:?}");
let refused = space::credential(&ctx, &outsider, &pool)
.await
.err()
.unwrap();
assert!(space::denied(&refused), "{refused:#}");
}
crates/grain-mcp/src/lib.rs+2−2
@@ -284,7 +284,7 @@ impl Server {
}
#[tool(
description = "As an admin of grain's appview (an account its GRAIN_ADMINS names), the reports, newest first, open ones unless status says otherwise. A child-safety report (label csae, or a child-safety reason type), and every report on an account with any child-safety report or label, is answered by its ids, label and dates alone: its text is never shown to a model, and it is for a person to handle with the grain command line."
description = "As an admin of grain's appview (an account its GRAIN_ADMIN_DIDS names), the reports, newest first, open ones unless status says otherwise. A child-safety report (label csae, or a child-safety reason type), and every report on an account with any child-safety report or label, is answered by its ids, label and dates alone: its text is never shown to a model, and it is for a person to handle with the grain command line."
)]
async fn list_reports(
&self,
@@ -413,7 +413,7 @@ impl Server {
#[tool_handler(
name = "grain",
instructions = "Moderation of grain, a photo sharing app on atproto. Report an account or record with report. An admin of grain's appview (an account its GRAIN_ADMINS names) reads reports with list_reports and an account's moderation with account, closes reports with resolve_report, takes accounts and records down or puts them back with takedown (see moderation_status), and gives out or negates labels with label (see list_label_definitions and query_labels). Anything about the safety of a child (a child-safety report, or the csae label) is never shown here: such a report or account is answered by its ids and dates alone, and acting on it is refused, for a person to handle with the grain command line (`grain admin reports --label csae`, `grain admin account <did>`). Do not try to work around this: tell the user it needs them."
instructions = "Moderation of grain, a photo sharing app on atproto. Report an account or record with report. An admin of grain's appview (an account its GRAIN_ADMIN_DIDS names) reads reports with list_reports and an account's moderation with account, closes reports with resolve_report, takes accounts and records down or puts them back with takedown (see moderation_status), and gives out or negates labels with label (see list_label_definitions and query_labels). Anything about the safety of a child (a child-safety report, or the csae label) is never shown here: such a report or account is answered by its ids and dates alone, and acting on it is refused, for a person to handle with the grain command line (`grain admin reports --label csae`, `grain admin account <did>`). Do not try to work around this: tell the user it needs them."
)]
impl ServerHandler for Server {}
crates/grain-serve/src/classify/clef.rs+16−15
crates/grain-serve/src/index/graph.rsnew+35−0
crates/grain-serve/src/index/mod.rs+3−0
crates/grain-serve/src/index/spaces.rsnew+76−0
crates/grain-serve/src/index/stories.rsnew+69−0
crates/grain-serve/src/login/carryover.rs+2−2
crates/grain-serve/src/push/apns.rs+20−18
crates/grain-serve/src/push/fcm.rs+5−5
crates/grain-serve/src/push/mod.rs+5−5
crates/grain-serve/src/push/tests.rs+17−17
crates/grain-serve/src/read/graph.rs+75−2
crates/grain-serve/src/read/mod.rs+6−0
crates/grain-serve/src/read/search.rs+1−1
crates/grain-serve/src/read/sitemap.rsnew+130−0
crates/grain-serve/src/read/story.rs+31−3
crates/grain-serve/src/read/tests.rs+1−0
crates/grain-serve/src/spaces/mod.rsnew+10−0
crates/grain-serve/src/spaces/support.rsnew+347−0
crates/grain-serve/src/backfill.rs+23−15
crates/grain-serve/src/cdn.rs+2−2
crates/grain-serve/src/did.rsnew+69−0
crates/grain-serve/src/graph.rsnew+80−0
crates/grain-serve/src/lib.rs+199−86
crates/grain-serve/src/login.rs+19−7
crates/grain-serve/src/main.rs+22−11
crates/grain-serve/src/moderation.rs+4−1
crates/grain-serve/src/preferences.rs+1−0
crates/grain-serve/src/story.rsnew+59−0
crates/grain-serve/src/viewer.rs+78−3
crates/grain-serve/src/write.rs+7−4
crates/grain-serve/tests/common/mod.rs+32−1
crates/grain-serve/tests/backfill.rs+32−18
crates/grain-serve/tests/cli.rs+1−1
crates/grain-serve/tests/graph.rsnew+251−0
crates/grain-serve/tests/health.rs+45−2
crates/grain-serve/tests/jetstream.rs+10−5
crates/grain-serve/tests/login.rs+4−4
crates/grain-serve/tests/main.rs+28−11
crates/grain-serve/tests/moderation.rs+8−1
crates/grain-serve/tests/notifications.rs+8−0
crates/grain-serve/tests/read.rs+20−0
crates/grain-serve/tests/sitemap.rsnew+175−0
crates/grain-serve/tests/spaces.rsnew+50−0
crates/grain-serve/tests/stories.rsnew+221−0
crates/grain-serve/tests/write.rs+8−0
harness/cov.mjs+14−6
harness/pds.mjs+93−5
test/web/grainServe.test.ts+2−0
web/src/lib/server/grainServe.ts+2−1
web/src/routes/.well-known/did.json/+server.tsdeleted+0−22
AGENTS.md+7−0
vite.config.ts+4−3