0
2
Groups are what spaces are for: no private galleries
Only what this pull adds. It is built on grain runs on a Rust server modelled on knotless, without hatk.
crates/grain/src/lexicon.rs+1−1
| @@ -1067,7 +1067,7 @@ mod tests { | ||
"at://did:plc:a/social.grain.gallery/3l3qo2vuowo2a" | ||
)); | ||
assert!(valid_at_uri( | ||
"at://did:plc:a/space/social.grain.privateGallery/k" | ||
"at://did:plc:a/space/fyi.opensocial.members/self" | ||
)); | ||
assert!(valid_at_uri( | ||
"at://did:plc:a/space/social.grain.group/k/did:plc:b/social.grain.gallery/r" | ||
crates/grain/src/oauth.rs+5−11
| @@ -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_SCOPE`] beside these, and | ||
/// only of a PDS that serves spaces: a login that needs them adds them to | ||
/// what it passes to [`authorize`]. | ||
/// 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 \ | ||
| @@ -52,8 +52,6 @@ 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 | ||
| @@ -61,10 +59,6 @@ repo:app.bsky.feed.post?action=create&action=update"; | ||
/// 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\ | ||
| @@ -996,9 +990,9 @@ pub(crate) mod tests { | ||
assert!(!SCOPE.contains(" ")); | ||
assert_eq!(scope(false), SCOPE); | ||
let spaces = scope(true); | ||
assert!(spaces.starts_with(&format!("{SCOPE} space:social.grain.privateGallery?"))); | ||
assert!(spaces.starts_with(&format!("{SCOPE} space:social.grain.group?"))); | ||
assert!(!spaces.contains(" ") && !spaces.ends_with(' ')); | ||
assert_eq!(spaces.split(' ').count(), SCOPE.split(' ').count() + 11); | ||
assert_eq!(spaces.split(' ').count(), SCOPE.split(' ').count() + 10); | ||
// 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.rs+11−60
| @@ -1,4 +1,4 @@ | ||
//! Permissioned spaces, where private galleries and groups' pools live. | ||
//! Permissioned spaces, where groups' pools and member lists live. | ||
//! | ||
//! A space is `at://<authority>/space/<type>/<skey>`: its authority is the | ||
//! account it is anchored on, which keeps its member list, and each member | ||
| @@ -32,10 +32,6 @@ use serde::Deserialize; | ||
use serde_json::{Value, json}; | ||
use std::sync::Arc; | ||
| ||
/// A private gallery's space type: one gallery a space, keyed by the | ||
/// gallery's rkey, anchored on its author, who alone writes there. | ||
pub const PRIVATE_GALLERY: &str = "social.grain.privateGallery"; | ||
| ||
/// A group's pool's space type: one a group, keyed `self`, anchored on the | ||
/// group, where each member writes their own galleries, favorites and | ||
/// comments. | ||
| @@ -394,12 +390,6 @@ pub struct Admin<'a> { | ||
pub session: &'a Session, | ||
} | ||
| ||
/// A member of a space, as its authority lists them. | ||
#[derive(Deserialize, Debug, Clone, PartialEq)] | ||
pub struct Member { | ||
pub did: String, | ||
} | ||
| ||
impl Admin<'_> { | ||
fn pds(&self) -> Xrpc { | ||
Xrpc::new(self.ctx.guarded.clone(), &self.session.service) | ||
| @@ -470,46 +460,6 @@ impl Admin<'_> { | ||
.map(drop) | ||
} | ||
| ||
/// The member list of `space`, as this account, its authority, keeps | ||
/// it: every page. | ||
pub async fn members(&self, space: &str) -> Result<Vec<Member>> { | ||
#[derive(Deserialize)] | ||
struct Page { | ||
#[serde(default)] | ||
members: Vec<Member>, | ||
cursor: Option<String>, | ||
} | ||
let auth = self.session.auth(); | ||
let pds = self.pds(); | ||
let mut nsid = "com.atproto.memberspace.listMembers"; | ||
let mut cursor: Option<String> = None; | ||
let mut out: Vec<Member> = Vec::new(); | ||
for _ in 0..MOST_PAGES { | ||
let mut params = vec![("space", space), ("limit", "100")]; | ||
if let Some(c) = &cursor { | ||
params.push(("cursor", c)); | ||
} | ||
let page: Page = match pds.query(nsid, ¶ms, Some(&auth)).await { | ||
Err(e) if not_served(&e) && nsid.contains("memberspace") => { | ||
nsid = "com.atproto.simplespace.listMembers"; | ||
pds.query(nsid, ¶ms, Some(&auth)).await? | ||
} | ||
answered => answered?, | ||
}; | ||
let empty = page.members.is_empty(); | ||
for m in page.members { | ||
if !out.contains(&m) { | ||
out.push(m); | ||
} | ||
} | ||
match page.cursor { | ||
Some(next) if !empty && cursor.as_ref() != Some(&next) => cursor = Some(next), | ||
_ => break, | ||
} | ||
} | ||
Ok(out) | ||
} | ||
| ||
/// Makes `writes`, each `{$type, collection, rkey, value}` with its | ||
/// `$type` `com.atproto.space.applyWrites#create`, `#update` or | ||
/// `#delete`, in this account's repo in `space`, at once. | ||
| @@ -656,12 +606,12 @@ mod tests { | ||
members("did:plc:g"), | ||
"at://did:plc:g/space/fyi.opensocial.members/self" | ||
); | ||
let named = parse(&uri("did:plc:a", PRIVATE_GALLERY, "3l")).unwrap(); | ||
let named = parse(&uri("did:plc:a", POOL, "3l")).unwrap(); | ||
assert_eq!( | ||
named, | ||
Named { | ||
authority: "did:plc:a".into(), | ||
kind: PRIVATE_GALLERY.into(), | ||
kind: POOL.into(), | ||
skey: "3l".into(), | ||
} | ||
); | ||
| @@ -676,7 +626,7 @@ mod tests { | ||
assert_eq!(parse(wrong), None, "{wrong}"); | ||
} | ||
assert!(of_kind(&pool("did:plc:g"), POOL).is_ok()); | ||
assert!(of_kind(&pool("did:plc:g"), PRIVATE_GALLERY).is_err()); | ||
assert!(of_kind(&pool("did:plc:g"), MEMBERS).is_err()); | ||
} | ||
| ||
#[test] | ||
| @@ -801,11 +751,14 @@ mod tests { | ||
ctx: &ctx, | ||
session: &session, | ||
}; | ||
let space = uri("did:plc:a", PRIVATE_GALLERY, "k"); | ||
let made = admin.create(PRIVATE_GALLERY, "k").await.unwrap_err(); | ||
let made = admin.create(POOL, "self").await.unwrap_err(); | ||
assert!(not_served(&made), "{made:#}"); | ||
assert!(admin.put_member(&space, "did:plc:b", false).await.is_err()); | ||
assert!(admin.members(&space).await.is_err()); | ||
assert!( | ||
admin | ||
.put_member(&pool("did:plc:a"), "did:plc:b", false) | ||
.await | ||
.is_err() | ||
); | ||
let asked = asked.lock().unwrap(); | ||
let methods: Vec<&str> = asked | ||
.iter() | ||
| @@ -825,8 +778,6 @@ mod tests { | ||
"/xrpc/com.atproto.simplespace.createSpace", | ||
"/xrpc/com.atproto.memberspace.putMember", | ||
"/xrpc/com.atproto.simplespace.addMember", | ||
"/xrpc/com.atproto.memberspace.listMembers", | ||
"/xrpc/com.atproto.simplespace.listMembers", | ||
] | ||
); | ||
assert!( | ||
crates/grain/tests/spaces.rs+39−103
| @@ -1,6 +1,6 @@ | ||
//! 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 | ||
//! (`GRAIN_DEV_ENV`): a group's pool made, members put on it, and its | ||
//! repos read with credentials and refused to everyone else. These | ||
//! pass trivially on a PDS without spaces. | ||
| ||
mod common; | ||
| @@ -28,107 +28,6 @@ async fn someone(ctx: &Ctx) -> Session { | ||
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() { | ||
| @@ -178,6 +77,21 @@ async fn a_pools_members_write_their_own_repos_and_read_each_others() { | ||
["c1"] | ||
); | ||
| ||
// And a photo, its blob uploaded as any other. | ||
let pds = Xrpc::new(ctx.guarded.clone(), &member.service); | ||
let blob = pds | ||
.upload_blob(PNG.to_vec(), "image/png", &member.auth()) | ||
.await | ||
.unwrap(); | ||
let photo = json!({"$type": "social.grain.photo", "photo": blob, | ||
"aspectRatio": {"width": 1, "height": 1}, "createdAt": keys::now_iso()}); | ||
let writes = [space::create("social.grain.photo", "p1", photo)]; | ||
mine.apply_writes(&pool, &writes).await.unwrap(); | ||
| ||
// The group finds the space among its own. | ||
let listed = space::list_spaces(&ctx, &group, space::POOL).await.unwrap(); | ||
assert!(listed.contains(&pool), "{listed:?}"); | ||
| ||
// 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(); | ||
| @@ -187,6 +101,19 @@ async fn a_pools_members_write_their_own_repos_and_read_each_others() { | ||
.await | ||
.unwrap(); | ||
assert_eq!(read[0].value["text"], "lovely"); | ||
assert!(credential.lasts()); | ||
assert_eq!(credential.reader, group.did); | ||
let photos = space::read_with(&ctx, &credential, &member.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, &member.did, &cid) | ||
.await | ||
.unwrap(); | ||
assert_eq!(bytes, PNG); | ||
| ||
// 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. | ||
| @@ -205,4 +132,13 @@ async fn a_pools_members_write_their_own_repos_and_read_each_others() { | ||
.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, &pool).await.unwrap(); | ||
let found = auth::verify_delegation(&ctx, &token).await.unwrap(); | ||
assert_eq!( | ||
(found.did, found.space), | ||
(outsider.did.clone(), pool.clone()) | ||
); | ||
} | ||
crates/grain-import/src/lib.rs+2−10
| @@ -30,7 +30,8 @@ | ||
//! label's sequence number and a report's id mean what they meant: | ||
//! `_labels` (an account's labels name it by its bare DID, and stay so), | ||
//! `_reports`, `_mutes`, `_preferences`, `_push_tokens`, `_story_views`, | ||
//! `_space_invites`, `_space_support`. | ||
//! `_space_support`. hatk's `_space_invites`, whom each private gallery | ||
//! was shared with, is not read: grain-serve has no private galleries. | ||
//! | ||
//! hatk gave its labels out as `admin`, from its admin routes, and `self`, | ||
//! from its label rules, which name no labeler: each is grain's, and given | ||
| @@ -470,15 +471,6 @@ pub const KEPT: &[Kept] = &[ | ||
select: "SELECT did, subject, created_at FROM _story_views", | ||
insert: "INSERT INTO story_view (did, subject, created_at) VALUES (?1, ?2, ?3)", | ||
}, | ||
Kept { | ||
table: "space_invite", | ||
source: "_space_invites", | ||
from: "_space_invites", | ||
count: "SELECT count(*) FROM _space_invites", | ||
select: "SELECT space, member_did, author_did, created_at FROM _space_invites", | ||
insert: "INSERT INTO space_invite (space, member_did, author_did, created_at) | ||
VALUES (?1, ?2, ?3, ?4)", | ||
}, | ||
Kept { | ||
table: "oauth_key", | ||
source: "_oauth_keys", | ||
crates/grain-import/src/tests.rs+0−26
| @@ -93,8 +93,6 @@ CREATE TABLE _mutes (did TEXT NOT NULL, subject TEXT NOT NULL, created_at TEXT N | ||
PRIMARY KEY (did, subject)); | ||
CREATE TABLE _space_support (pds_endpoint TEXT PRIMARY KEY, supported INTEGER NOT NULL, | ||
missing TEXT NOT NULL, checked_at TEXT NOT NULL); | ||
CREATE TABLE _space_invites (space TEXT NOT NULL, member_did TEXT NOT NULL, | ||
author_did TEXT NOT NULL, created_at TEXT NOT NULL, PRIMARY KEY (space, member_did)); | ||
CREATE TABLE _story_views (did TEXT NOT NULL, subject TEXT NOT NULL, created_at TEXT NOT NULL, | ||
PRIMARY KEY (did, subject)); | ||
"#; | ||
| @@ -141,9 +139,6 @@ INSERT INTO _mutes VALUES | ||
INSERT INTO _story_views VALUES | ||
('did:plc:alice', 'at://did:plc:bob/social.grain.story/3l3qo2vuowo2a', '2025-01-01T00:00:00.000Z'), | ||
('did:plc:carol', 'at://did:plc:bob/social.grain.story/3l3qo2vuowo2a', '2025-01-02T00:00:00.000Z'); | ||
INSERT INTO _space_invites VALUES | ||
('at://did:plc:alice/space/social.grain.privateGallery/one', 'did:plc:bob', 'did:plc:alice', '2025-01-01T00:00:00.000Z'), | ||
('at://did:plc:alice/space/social.grain.privateGallery/one', 'did:plc:carol', 'did:plc:alice', '2025-01-02T00:00:00.000Z'); | ||
INSERT INTO _space_support VALUES | ||
('https://pds.example', 1, '[]', '2025-01-01T00:00:00.000Z'), | ||
('https://old.example', 0, '["com.atproto.space.createSpace"]', '2025-01-02T00:00:00.000Z'); | ||
| @@ -433,15 +428,6 @@ fn every_row_of_what_the_network_cannot_rebuild_is_imported() { | ||
"did:plc:carol|at://did:plc:bob/social.grain.story/3l3qo2vuowo2a|2025-01-02T00:00:00.000Z", | ||
] | ||
); | ||
assert_eq!( | ||
rows( | ||
"SELECT space, member_did, author_did, created_at FROM space_invite ORDER BY member_did" | ||
), | ||
[ | ||
"at://did:plc:alice/space/social.grain.privateGallery/one|did:plc:bob|did:plc:alice|2025-01-01T00:00:00.000Z", | ||
"at://did:plc:alice/space/social.grain.privateGallery/one|did:plc:carol|did:plc:alice|2025-01-02T00:00:00.000Z", | ||
] | ||
); | ||
assert_eq!( | ||
rows( | ||
"SELECT pds_endpoint, supported, missing, checked_at FROM space_support ORDER BY checked_at" | ||
| @@ -472,7 +458,6 @@ fn every_row_of_what_the_network_cannot_rebuild_is_imported() { | ||
("notification_pref", 4, 4), | ||
("push_token", 4, 4), | ||
("story_view", 2, 2), | ||
("space_invite", 2, 2), | ||
("oauth_key", 1, 1), | ||
("space_support", 2, 2), | ||
] | ||
| @@ -533,17 +518,6 @@ fn every_row_of_what_the_network_cannot_rebuild_is_imported() { | ||
.map(|l| l.neg) | ||
.collect(); | ||
assert_eq!(negated, [false, true]); | ||
// A private gallery shared with bob is among the ones shared with | ||
// him, as grain-serve lists them, named as a private gallery's space. | ||
let shared = index.invites("did:plc:bob", 10).unwrap(); | ||
let shared: Vec<(&str, &str)> = shared | ||
.iter() | ||
.map(|i| (i.space.as_str(), i.author.as_str())) | ||
.collect(); | ||
let space = "at://did:plc:alice/space/social.grain.privateGallery/one"; | ||
assert_eq!(shared, [(space, "did:plc:alice")]); | ||
let named = grain::space::of_kind(space, grain::space::PRIVATE_GALLERY).unwrap(); | ||
assert_eq!(named.authority, "did:plc:alice"); | ||
// What a PDS was found to serve is what grain-serve asks before it | ||
// asks the PDS again. | ||
let (found, _) = index.space_support("https://pds.example").unwrap().unwrap(); | ||
crates/grain-serve/src/index/mod.rs+1−2
| @@ -138,7 +138,7 @@ pub fn row<T: Record>(r: &rusqlite::Row) -> rusqlite::Result<Row<T>> { | ||
/// What grain keeps for an account, which goes with its records | ||
/// ([`Index::purge`]): its web session, preferences, how and when it | ||
/// reads its notifications, the devices they are pushed to, whom it | ||
/// muted, the stories it watched, and its invitations to spaces. | ||
/// muted, and the stories it watched. | ||
pub const KEPT_FOR: &[&str] = &[ | ||
"DELETE FROM web_session WHERE did = ?1", | ||
"DELETE FROM preference WHERE did = ?1", | ||
| @@ -147,7 +147,6 @@ pub const KEPT_FOR: &[&str] = &[ | ||
"DELETE FROM push_token WHERE did = ?1", | ||
"DELETE FROM mute WHERE did = ?1", | ||
"DELETE FROM story_view WHERE did = ?1", | ||
"DELETE FROM space_invite WHERE member_did = ?1 OR author_did = ?1", | ||
]; | ||
| ||
/// The author, collection and key of a record's AT-URI. | ||
crates/grain-serve/src/index/schema.rs+7−3
| @@ -68,8 +68,7 @@ | ||
//! (`com.atproto.moderation.defs#reasonType`, none for one made before | ||
//! grain took them by it) and its `reason`, who made it, and whether it is | ||
//! `open`, `resolved` or `dismissed`, by whom and when; | ||
//! - `mute`, `preference`, `push_token`, `story_view`, `space_invite`, | ||
//! `space_support`; | ||
//! - `mute`, `preference`, `push_token`, `story_view`, `space_support`; | ||
//! - `notification_seen`, when each account last saw its notifications | ||
//! (`updateSeen`'s `seenAt`, in UTC as [`grain::keys::iso`] writes it), | ||
//! and `notification_pref`, how each kind of notification reaches it | ||
| @@ -113,7 +112,7 @@ | ||
| ||
/// Each step of the schema, run once, in order, by `PRAGMA user_version`. | ||
/// A step is only ever appended: one that has run is never changed. | ||
pub const MIGRATIONS: &[&str] = &[V1, V2, V3, V4, V5, V6, V7, V8, V9, V10, V11]; | ||
pub const MIGRATIONS: &[&str] = &[V1, V2, V3, V4, V5, V6, V7, V8, V9, V10, V11, V12]; | ||
| ||
const V1: &str = " | ||
CREATE TABLE gallery ( | ||
| @@ -517,3 +516,8 @@ const V11: &str = " | ||
ALTER TABLE report ADD COLUMN reason_type TEXT; | ||
CREATE INDEX report_subject_did ON report (subject_did, id); | ||
"; | ||
| ||
/// No private galleries, so no record of whom one was shared with. | ||
const V12: &str = " | ||
DROP TABLE space_invite; | ||
"; | ||
crates/grain-serve/src/index/spaces.rs+2−78
| @@ -1,9 +1,6 @@ | ||
//! What grain keeps of permissioned spaces: `space_support`, whether each | ||
//! PDS was last found to serve them ([`crate::spaces::support`]), and | ||
//! `space_invite`, whom grain shared each private gallery with, for them | ||
//! to find it by ([`crate::spaces::gallery`]): a space leaves no trace on | ||
//! a member's own PDS. An invite grants nothing; the space's authority | ||
//! does. Neither is rebuilt from the network. | ||
//! PDS was last found to serve them ([`crate::spaces::support`]). It is | ||
//! not rebuilt from the network. | ||
| ||
use super::Index; | ||
use crate::spaces::Support; | ||
| @@ -46,51 +43,6 @@ impl Index { | ||
} | ||
} | ||
| ||
const INVITES: &str = "SELECT space, author_did, created_at FROM space_invite v | ||
WHERE v.member_did = ?1 ORDER BY v.created_at DESC LIMIT ?2"; | ||
| ||
/// A private gallery shared with an account, as grain shared it. | ||
#[derive(Debug, Clone, PartialEq)] | ||
pub struct Invite { | ||
pub space: String, | ||
pub author: String, | ||
pub created_at: String, | ||
} | ||
| ||
impl Index { | ||
/// Keeps that `author` shared `space` with `member` at `at`, unless | ||
/// it was kept before. | ||
pub fn invite( | ||
&self, | ||
space: &str, | ||
member: &str, | ||
author: &str, | ||
at: &str, | ||
) -> rusqlite::Result<()> { | ||
let db = self.db.lock().unwrap(); | ||
db.execute( | ||
"INSERT INTO space_invite (space, member_did, author_did, created_at) | ||
VALUES (?1, ?2, ?3, ?4) ON CONFLICT (space, member_did) DO NOTHING", | ||
params![space, member, author, at], | ||
)?; | ||
Ok(()) | ||
} | ||
| ||
/// The `limit` galleries shared with `member` latest. | ||
pub fn invites(&self, member: &str, limit: usize) -> rusqlite::Result<Vec<Invite>> { | ||
let db = self.db.lock().unwrap(); | ||
let mut stmt = db.prepare(INVITES)?; | ||
let rows = stmt.query_map(params![member, limit as i64], |r| { | ||
Ok(Invite { | ||
space: r.get(0)?, | ||
author: r.get(1)?, | ||
created_at: r.get(2)?, | ||
}) | ||
})?; | ||
rows.collect() | ||
} | ||
} | ||
| ||
#[cfg(test)] | ||
mod tests { | ||
use super::*; | ||
| @@ -121,32 +73,4 @@ mod tests { | ||
let (_, age) = index.space_support("https://pds.test").unwrap().unwrap(); | ||
assert_eq!(age, i64::MAX); | ||
} | ||
| ||
#[test] | ||
fn invites_are_kept_once_and_read_latest_first() { | ||
let index = Index::memory().unwrap(); | ||
let at = |n: u8| format!("2026-10-3{n}T00:00:00.000Z"); | ||
index | ||
.invite("at://a/space/t/1", "did:plc:m", "did:plc:a", &at(0)) | ||
.unwrap(); | ||
index | ||
.invite("at://a/space/t/2", "did:plc:m", "did:plc:a", &at(1)) | ||
.unwrap(); | ||
index | ||
.invite("at://a/space/t/1", "did:plc:m", "did:plc:a", &at(1)) | ||
.unwrap(); | ||
index | ||
.invite("at://a/space/t/1", "did:plc:n", "did:plc:a", &at(0)) | ||
.unwrap(); | ||
let found = index.invites("did:plc:m", 10).unwrap(); | ||
let spaces: Vec<&str> = found.iter().map(|i| i.space.as_str()).collect(); | ||
assert_eq!(spaces, ["at://a/space/t/2", "at://a/space/t/1"]); | ||
assert_eq!(found[1].created_at, at(0), "kept once"); | ||
assert_eq!(found[1].author, "did:plc:a"); | ||
assert_eq!(index.invites("did:plc:m", 1).unwrap().len(), 1); | ||
index.read(|db| { | ||
super::super::plan::sized(db); | ||
assert_eq!(super::super::plan::scans(db, INVITES), Vec::<String>::new()); | ||
}); | ||
} | ||
} | ||
crates/grain-serve/src/index/tests.rs+1−4
| @@ -349,7 +349,7 @@ fn the_tables_have_the_rows_columns() { | ||
assert_eq!(stmt, *columns, "{table}"); | ||
} | ||
assert_eq!(table_of("social.grain.story"), Some("story")); | ||
assert_eq!(table_of("social.grain.privateGallery"), None); | ||
assert_eq!(table_of("social.grain.group"), None); | ||
// A JSON column that holds what is not JSON fails to read. | ||
let at = uri("social.grain.gallery"); | ||
index.put(&at, CID, &gallery("Fog")).unwrap(); | ||
| @@ -675,7 +675,6 @@ fn a_purge_takes_what_grain_keeps_for_the_account_and_leaves_what_admins_hold() | ||
"INSERT INTO push_token VALUES (?1, 'token', 'ios', '')", | ||
"INSERT INTO mute VALUES (?1, 'did:plc:carol', '')", | ||
"INSERT INTO story_view VALUES (?1, 'at://did:plc:carol/social.grain.story/s', '')", | ||
"INSERT INTO space_invite VALUES ('ats://space', ?1, 'did:plc:carol', '')", | ||
"INSERT INTO takedown VALUES (?1, '')", | ||
"INSERT INTO report (subject_uri, subject_did, label, reported_by, created_at) | ||
VALUES (?1, ?1, 'spam', 'did:plc:carol', '')", | ||
| @@ -693,7 +692,6 @@ fn a_purge_takes_what_grain_keeps_for_the_account_and_leaves_what_admins_hold() | ||
assert_eq!(index.purge(DID).unwrap(), 1); | ||
let count = |table: &str, did: &str| -> i64 { | ||
let column = match table { | ||
"space_invite" => "member_did", | ||
"report" => "subject_did", | ||
"label" => "uri", | ||
_ => "did", | ||
| @@ -709,7 +707,6 @@ fn a_purge_takes_what_grain_keeps_for_the_account_and_leaves_what_admins_hold() | ||
"push_token", | ||
"mute", | ||
"story_view", | ||
"space_invite", | ||
] { | ||
assert_eq!(count(table, DID), 0, "{table}"); | ||
assert!(count(table, other) >= 1, "{table} of another"); | ||
crates/grain-serve/src/spaces/blob.rs+1−1
crates/grain-serve/src/spaces/gallery.rsdeleted+0−544
544 lines changed: not loaded until asked for.
crates/grain-serve/src/spaces/group.rs+7−8
crates/grain-serve/src/spaces/mod.rs+56−35
crates/grain-serve/src/spaces/pool.rs+118−14
crates/grain-serve/src/spaces/support.rs+1−1
crates/grain-serve/src/login.rs+2−6
crates/grain-serve/tests/spaces.rs+169−342
511 lines changed: not loaded until asked for.