0
2
Seven reviewed pulls land on feature/rust together, checked once
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 {} | ||
| ||