···11+CREATE TABLE IF NOT EXISTS spaces (
22+ id TEXT PRIMARY KEY,
33+ owner_did TEXT NOT NULL,
44+ type_nsid TEXT NOT NULL,
55+ skey TEXT NOT NULL,
66+ display_name TEXT,
77+ description TEXT,
88+ access_mode TEXT NOT NULL DEFAULT 'default_allow',
99+ app_allowlist TEXT,
1010+ app_denylist TEXT,
1111+ managing_app_did TEXT,
1212+ config TEXT NOT NULL DEFAULT '{}',
1313+ created_at TEXT NOT NULL,
1414+ updated_at TEXT NOT NULL,
1515+ UNIQUE(owner_did, type_nsid, skey)
1616+);
1717+1818+CREATE INDEX idx_spaces_owner_did ON spaces(owner_did);
1919+CREATE INDEX idx_spaces_type_nsid ON spaces(type_nsid);
···11+CREATE TABLE IF NOT EXISTS space_members (
22+ id TEXT PRIMARY KEY,
33+ space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
44+ member_did TEXT NOT NULL,
55+ access TEXT NOT NULL DEFAULT 'read',
66+ is_delegation INTEGER NOT NULL DEFAULT 0,
77+ granted_by TEXT,
88+ created_at TEXT NOT NULL,
99+ UNIQUE(space_id, member_did)
1010+);
1111+1212+CREATE INDEX idx_space_members_did ON space_members(member_did);
1313+CREATE INDEX idx_space_members_space_id ON space_members(space_id);
···11+CREATE TABLE IF NOT EXISTS space_records (
22+ uri TEXT PRIMARY KEY,
33+ space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
44+ author_did TEXT NOT NULL,
55+ collection TEXT NOT NULL,
66+ rkey TEXT NOT NULL,
77+ record TEXT NOT NULL,
88+ cid TEXT NOT NULL,
99+ indexed_at TEXT NOT NULL
1010+);
1111+1212+CREATE INDEX idx_space_records_space_id ON space_records(space_id);
1313+CREATE INDEX idx_space_records_author ON space_records(author_did);
1414+CREATE INDEX idx_space_records_collection ON space_records(space_id, collection);
···11+CREATE TABLE IF NOT EXISTS space_credentials (
22+ id TEXT PRIMARY KEY,
33+ space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
44+ issued_to TEXT NOT NULL,
55+ token_hash TEXT NOT NULL,
66+ expires_at TEXT NOT NULL,
77+ created_at TEXT NOT NULL
88+);
99+1010+CREATE INDEX idx_space_credentials_space_id ON space_credentials(space_id);
···11+CREATE TABLE IF NOT EXISTS space_invites (
22+ id TEXT PRIMARY KEY,
33+ space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
44+ token_hash TEXT NOT NULL UNIQUE,
55+ created_by TEXT NOT NULL,
66+ access TEXT NOT NULL DEFAULT 'read',
77+ max_uses INTEGER,
88+ uses INTEGER NOT NULL DEFAULT 0,
99+ expires_at TEXT,
1010+ revoked INTEGER NOT NULL DEFAULT 0,
1111+ created_at TEXT NOT NULL
1212+);
1313+1414+CREATE INDEX idx_space_invites_space_id ON space_invites(space_id);
···11+CREATE TABLE IF NOT EXISTS space_dids (
22+ id TEXT PRIMARY KEY,
33+ did TEXT NOT NULL UNIQUE,
44+ space_id TEXT REFERENCES spaces(id) ON DELETE SET NULL,
55+ signing_key_enc BYTEA NOT NULL,
66+ rotation_key_enc BYTEA NOT NULL,
77+ created_by TEXT NOT NULL,
88+ created_at TEXT NOT NULL
99+);
1010+1111+CREATE INDEX idx_space_dids_did ON space_dids(did);
1212+CREATE INDEX idx_space_dids_space_id ON space_dids(space_id);
···11+CREATE TABLE IF NOT EXISTS space_sync_state (
22+ id TEXT PRIMARY KEY,
33+ space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
44+ member_did TEXT NOT NULL,
55+ cursor TEXT,
66+ last_synced_at TEXT,
77+ status TEXT NOT NULL DEFAULT 'pending',
88+ error TEXT,
99+ UNIQUE(space_id, member_did)
1010+);
1111+1212+CREATE INDEX idx_space_sync_state_space_id ON space_sync_state(space_id);
···11+CREATE TABLE IF NOT EXISTS spaces (
22+ id TEXT PRIMARY KEY,
33+ owner_did TEXT NOT NULL,
44+ type_nsid TEXT NOT NULL,
55+ skey TEXT NOT NULL,
66+ display_name TEXT,
77+ description TEXT,
88+ access_mode TEXT NOT NULL DEFAULT 'default_allow',
99+ app_allowlist TEXT,
1010+ app_denylist TEXT,
1111+ managing_app_did TEXT,
1212+ config TEXT NOT NULL DEFAULT '{}',
1313+ created_at TEXT NOT NULL,
1414+ updated_at TEXT NOT NULL,
1515+ UNIQUE(owner_did, type_nsid, skey)
1616+);
1717+1818+CREATE INDEX idx_spaces_owner_did ON spaces(owner_did);
1919+CREATE INDEX idx_spaces_type_nsid ON spaces(type_nsid);
···11+CREATE TABLE IF NOT EXISTS space_members (
22+ id TEXT PRIMARY KEY,
33+ space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
44+ member_did TEXT NOT NULL,
55+ access TEXT NOT NULL DEFAULT 'read',
66+ is_delegation INTEGER NOT NULL DEFAULT 0,
77+ granted_by TEXT,
88+ created_at TEXT NOT NULL,
99+ UNIQUE(space_id, member_did)
1010+);
1111+1212+CREATE INDEX idx_space_members_did ON space_members(member_did);
1313+CREATE INDEX idx_space_members_space_id ON space_members(space_id);
···11+CREATE TABLE IF NOT EXISTS space_records (
22+ uri TEXT PRIMARY KEY,
33+ space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
44+ author_did TEXT NOT NULL,
55+ collection TEXT NOT NULL,
66+ rkey TEXT NOT NULL,
77+ record TEXT NOT NULL,
88+ cid TEXT NOT NULL,
99+ indexed_at TEXT NOT NULL
1010+);
1111+1212+CREATE INDEX idx_space_records_space_id ON space_records(space_id);
1313+CREATE INDEX idx_space_records_author ON space_records(author_did);
1414+CREATE INDEX idx_space_records_collection ON space_records(space_id, collection);
···11+CREATE TABLE IF NOT EXISTS space_credentials (
22+ id TEXT PRIMARY KEY,
33+ space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
44+ issued_to TEXT NOT NULL,
55+ token_hash TEXT NOT NULL,
66+ expires_at TEXT NOT NULL,
77+ created_at TEXT NOT NULL
88+);
99+1010+CREATE INDEX idx_space_credentials_space_id ON space_credentials(space_id);
···11+CREATE TABLE IF NOT EXISTS space_invites (
22+ id TEXT PRIMARY KEY,
33+ space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
44+ token_hash TEXT NOT NULL UNIQUE,
55+ created_by TEXT NOT NULL,
66+ access TEXT NOT NULL DEFAULT 'read',
77+ max_uses INTEGER,
88+ uses INTEGER NOT NULL DEFAULT 0,
99+ expires_at TEXT,
1010+ revoked INTEGER NOT NULL DEFAULT 0,
1111+ created_at TEXT NOT NULL
1212+);
1313+1414+CREATE INDEX idx_space_invites_space_id ON space_invites(space_id);
···11+CREATE TABLE IF NOT EXISTS space_dids (
22+ id TEXT PRIMARY KEY,
33+ did TEXT NOT NULL UNIQUE,
44+ space_id TEXT REFERENCES spaces(id) ON DELETE SET NULL,
55+ signing_key_enc BLOB NOT NULL,
66+ rotation_key_enc BLOB NOT NULL,
77+ created_by TEXT NOT NULL,
88+ created_at TEXT NOT NULL
99+);
1010+1111+CREATE INDEX idx_space_dids_did ON space_dids(did);
1212+CREATE INDEX idx_space_dids_space_id ON space_dids(space_id);
···11+CREATE TABLE IF NOT EXISTS space_sync_state (
22+ id TEXT PRIMARY KEY,
33+ space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
44+ member_did TEXT NOT NULL,
55+ cursor TEXT,
66+ last_synced_at TEXT,
77+ status TEXT NOT NULL DEFAULT 'pending',
88+ error TEXT,
99+ UNIQUE(space_id, member_did)
1010+);
1111+1212+CREATE INDEX idx_space_sync_state_space_id ON space_sync_state(space_id);
···8585 pub index_hook: Option<String>,
8686 /// Optional per-NSID token cost for rate limiting.
8787 pub token_cost: Option<u32>,
8888+ /// Optional space type NSID indicating this lexicon is designed for use within spaces of that type.
8989+ pub space_type: Option<String>,
8890}
89919092impl ParsedLexicon {
···127129 let output = main_def.and_then(|m| m.get("output")).cloned();
128130 let record_schema = main_def.and_then(|m| m.get("record")).cloned();
129131132132+ let space_type = raw
133133+ .get("spaceType")
134134+ .and_then(|v| v.as_str())
135135+ .map(|s| s.to_string());
136136+130137 Ok(Self {
131138 id,
132139 lexicon_type,
···142149 script,
143150 index_hook,
144151 token_cost,
152152+ space_type,
145153 })
146154 }
147155}
···792800 let reg = LexiconRegistry::new();
793801 let script = reg.get_index_hook("nonexistent").await;
794802 assert!(script.is_none());
803803+ }
804804+805805+ #[test]
806806+ fn parse_space_type_from_lexicon() {
807807+ let raw = json!({
808808+ "lexicon": 1,
809809+ "id": "com.example.forum.post",
810810+ "spaceType": "com.example.forum",
811811+ "defs": {
812812+ "main": {
813813+ "type": "record",
814814+ "key": "tid",
815815+ "record": {
816816+ "type": "object",
817817+ "properties": {
818818+ "text": { "type": "string" }
819819+ }
820820+ }
821821+ }
822822+ }
823823+ });
824824+ let parsed =
825825+ ParsedLexicon::parse(raw, 1, None, ProcedureAction::Upsert, None, None, None).unwrap();
826826+ assert_eq!(parsed.space_type.as_deref(), Some("com.example.forum"));
827827+ }
828828+829829+ #[test]
830830+ fn parse_space_type_none_by_default() {
831831+ let parsed = ParsedLexicon::parse(
832832+ record_lexicon_json(),
833833+ 1,
834834+ None,
835835+ ProcedureAction::Upsert,
836836+ None,
837837+ None,
838838+ None,
839839+ )
840840+ .unwrap();
841841+ assert!(parsed.space_type.is_none());
795842 }
796843}
+1
src/lib.rs
···2323pub mod repo;
2424pub mod resolve;
2525pub mod server;
2626+pub mod spaces;
2627pub mod xrpc;
27282829use auth::oauth_store::{DbSessionStore, DbStateStore};
+192
src/lua/atproto_api.rs
···257257 atproto_table.set("verify_signature", verify_fn)?;
258258 }
259259260260+ // atproto.spaces sub-table
261261+ let spaces_table = lua.create_table()?;
262262+263263+ // atproto.spaces.is_member(space_uri, did) -> boolean
264264+ let state_clone = state.clone();
265265+ let is_member_fn =
266266+ lua.create_async_function(move |_lua, (space_uri, did): (String, String)| {
267267+ let state = state_clone.clone();
268268+ async move {
269269+ let uri = crate::spaces::SpaceUri::parse(&space_uri)
270270+ .map_err(|e| mlua::Error::runtime(format!("invalid space URI: {e}")))?;
271271+ let space = crate::spaces::db::get_space_by_address(
272272+ &state.db,
273273+ state.db_backend,
274274+ &uri.owner_did,
275275+ &uri.type_nsid,
276276+ &uri.skey,
277277+ )
278278+ .await
279279+ .map_err(|e| mlua::Error::runtime(format!("space lookup failed: {e}")))?;
280280+ let space = match space {
281281+ Some(s) => s,
282282+ None => return Ok(false),
283283+ };
284284+ let access =
285285+ crate::spaces::members::is_member(&state.db, state.db_backend, &space.id, &did)
286286+ .await
287287+ .map_err(|e| {
288288+ mlua::Error::runtime(format!("membership check failed: {e}"))
289289+ })?;
290290+ Ok(access.is_some())
291291+ }
292292+ })?;
293293+ spaces_table.set("is_member", is_member_fn)?;
294294+295295+ // atproto.spaces.get_access(space_uri, did) -> 'read' | 'write' | nil
296296+ let state_clone = state.clone();
297297+ let get_access_fn =
298298+ lua.create_async_function(move |_lua, (space_uri, did): (String, String)| {
299299+ let state = state_clone.clone();
300300+ async move {
301301+ let uri = crate::spaces::SpaceUri::parse(&space_uri)
302302+ .map_err(|e| mlua::Error::runtime(format!("invalid space URI: {e}")))?;
303303+ let space = crate::spaces::db::get_space_by_address(
304304+ &state.db,
305305+ state.db_backend,
306306+ &uri.owner_did,
307307+ &uri.type_nsid,
308308+ &uri.skey,
309309+ )
310310+ .await
311311+ .map_err(|e| mlua::Error::runtime(format!("space lookup failed: {e}")))?;
312312+ let space = match space {
313313+ Some(s) => s,
314314+ None => return Ok(None),
315315+ };
316316+ let access =
317317+ crate::spaces::members::is_member(&state.db, state.db_backend, &space.id, &did)
318318+ .await
319319+ .map_err(|e| {
320320+ mlua::Error::runtime(format!("membership check failed: {e}"))
321321+ })?;
322322+ Ok(access.map(|a| a.as_str().to_string()))
323323+ }
324324+ })?;
325325+ spaces_table.set("get_access", get_access_fn)?;
326326+327327+ // atproto.spaces.list_members(space_uri) -> array of { did, access }
328328+ let state_clone = state.clone();
329329+ let list_members_fn = lua.create_async_function(move |lua, space_uri: String| {
330330+ let state = state_clone.clone();
331331+ async move {
332332+ let uri = crate::spaces::SpaceUri::parse(&space_uri)
333333+ .map_err(|e| mlua::Error::runtime(format!("invalid space URI: {e}")))?;
334334+ let space = crate::spaces::db::get_space_by_address(
335335+ &state.db,
336336+ state.db_backend,
337337+ &uri.owner_did,
338338+ &uri.type_nsid,
339339+ &uri.skey,
340340+ )
341341+ .await
342342+ .map_err(|e| mlua::Error::runtime(format!("space lookup failed: {e}")))?;
343343+ let space = match space {
344344+ Some(s) => s,
345345+ None => {
346346+ return Err(mlua::Error::runtime("space not found"));
347347+ }
348348+ };
349349+ let members =
350350+ crate::spaces::members::resolve_members(&state.db, state.db_backend, &space.id)
351351+ .await
352352+ .map_err(|e| mlua::Error::runtime(format!("member resolution failed: {e}")))?;
353353+354354+ let result = lua.create_table()?;
355355+ for (i, member) in members.iter().enumerate() {
356356+ let entry = lua.create_table()?;
357357+ entry.set("did", member.did.as_str())?;
358358+ entry.set("access", member.access.as_str())?;
359359+ result.set(i + 1, entry)?;
360360+ }
361361+ Ok(mlua::Value::Table(result))
362362+ }
363363+ })?;
364364+ spaces_table.set("list_members", list_members_fn)?;
365365+366366+ // atproto.spaces.query({ space_uri, collection, limit, cursor }) -> { records, cursor }
367367+ let state_clone = state.clone();
368368+ let query_fn = lua.create_async_function(move |lua, opts: mlua::Table| {
369369+ let state = state_clone.clone();
370370+ async move {
371371+ let space_uri: String = opts
372372+ .get("space_uri")
373373+ .map_err(|_| mlua::Error::runtime("space_uri is required"))?;
374374+ let collection: Option<String> = opts.get("collection").ok();
375375+ let limit: i64 = opts.get("limit").unwrap_or(50);
376376+ let cursor: Option<String> = opts.get("cursor").ok();
377377+378378+ let uri = crate::spaces::SpaceUri::parse(&space_uri)
379379+ .map_err(|e| mlua::Error::runtime(format!("invalid space URI: {e}")))?;
380380+ let space = crate::spaces::db::get_space_by_address(
381381+ &state.db,
382382+ state.db_backend,
383383+ &uri.owner_did,
384384+ &uri.type_nsid,
385385+ &uri.skey,
386386+ )
387387+ .await
388388+ .map_err(|e| mlua::Error::runtime(format!("space lookup failed: {e}")))?;
389389+ let space = match space {
390390+ Some(s) => s,
391391+ None => {
392392+ return Err(mlua::Error::runtime("space not found"));
393393+ }
394394+ };
395395+396396+ let records = crate::spaces::db::list_space_records(
397397+ &state.db,
398398+ state.db_backend,
399399+ &space.id,
400400+ collection.as_deref(),
401401+ limit.min(100),
402402+ cursor.as_deref(),
403403+ )
404404+ .await
405405+ .map_err(|e| mlua::Error::runtime(format!("record query failed: {e}")))?;
406406+407407+ let next_cursor = records.last().map(|r| r.indexed_at.clone());
408408+409409+ let result = lua.create_table()?;
410410+ let records_table = lua.create_table()?;
411411+ for (i, record) in records.iter().enumerate() {
412412+ let entry = lua.to_value(&serde_json::json!({
413413+ "uri": record.uri,
414414+ "collection": record.collection,
415415+ "rkey": record.rkey,
416416+ "record": record.record,
417417+ "cid": record.cid,
418418+ "authorDid": record.author_did,
419419+ }))?;
420420+ records_table.set(i + 1, entry)?;
421421+ }
422422+ result.set("records", records_table)?;
423423+ match next_cursor {
424424+ Some(c) => result.set("cursor", c)?,
425425+ None => result.set("cursor", mlua::Value::Nil)?,
426426+ }
427427+428428+ Ok(mlua::Value::Table(result))
429429+ }
430430+ })?;
431431+ spaces_table.set("query", query_fn)?;
432432+433433+ atproto_table.set("spaces", spaces_table)?;
434434+260435 lua.globals().set("atproto", atproto_table)?;
261436 Ok(())
262437}
···516691 "#;
517692 let result: bool = lua.load(chunk).eval_async().await.unwrap();
518693 assert!(!result);
694694+ }
695695+696696+ #[tokio::test]
697697+ async fn spaces_api_is_registered() {
698698+ let state = test_state_with_plc("");
699699+ let lua = mlua::Lua::new();
700700+ register_atproto_api(&lua, Arc::new(state), None).unwrap();
701701+702702+ let chunk = r#"
703703+ return type(atproto.spaces) == "table"
704704+ and type(atproto.spaces.is_member) == "function"
705705+ and type(atproto.spaces.get_access) == "function"
706706+ and type(atproto.spaces.list_members) == "function"
707707+ and type(atproto.spaces.query) == "function"
708708+ "#;
709709+ let result: bool = lua.load(chunk).eval_async().await.unwrap();
710710+ assert!(result);
519711 }
520712}