···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 delegated_accounts (
22+ account_did TEXT PRIMARY KEY,
33+ linked_by TEXT NOT NULL,
44+ api_client_id TEXT NOT NULL,
55+ created_at TEXT NOT NULL
66+);
···11+CREATE TABLE IF NOT EXISTS account_delegates (
22+ account_did TEXT NOT NULL REFERENCES delegated_accounts(account_did) ON DELETE CASCADE,
33+ user_did TEXT NOT NULL,
44+ role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'member')),
55+ granted_by TEXT NOT NULL,
66+ created_at TEXT NOT NULL,
77+ PRIMARY KEY (account_did, user_did)
88+);
···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);
···11+CREATE TABLE IF NOT EXISTS delegated_accounts (
22+ account_did TEXT PRIMARY KEY,
33+ linked_by TEXT NOT NULL,
44+ api_client_id TEXT NOT NULL,
55+ created_at TEXT NOT NULL
66+);
···11+CREATE TABLE IF NOT EXISTS account_delegates (
22+ account_did TEXT NOT NULL REFERENCES delegated_accounts(account_did) ON DELETE CASCADE,
33+ user_did TEXT NOT NULL,
44+ role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'member')),
55+ granted_by TEXT NOT NULL,
66+ created_at TEXT NOT NULL,
77+ PRIMARY KEY (account_did, user_did)
88+);
···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}
+4
src/lib.rs
···22pub mod auth;
33pub mod config;
44pub mod db;
55+pub mod delegation;
56pub mod dev_happyview;
67pub mod dns;
78pub mod domain;
···910pub mod error;
1011pub mod event_log;
1112pub mod external_auth;
1313+pub mod feature_flags;
1414+pub mod feature_middleware;
1215pub mod jetstream;
1316pub mod labeler;
1417pub mod lexicon;
···2326pub mod repo;
2427pub mod resolve;
2528pub mod server;
2929+pub mod spaces;
2630pub mod xrpc;
27312832use auth::oauth_store::{DbSessionStore, DbStateStore};
+228
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+ if !crate::feature_flags::is_enabled(
270270+ &state.db,
271271+ crate::feature_flags::FeatureFlag::SPACES_ENABLED,
272272+ state.db_backend,
273273+ )
274274+ .await
275275+ {
276276+ return Err(mlua::Error::runtime("spaces feature is not enabled"));
277277+ }
278278+ let uri = crate::spaces::SpaceUri::parse(&space_uri)
279279+ .map_err(|e| mlua::Error::runtime(format!("invalid space URI: {e}")))?;
280280+ let space = crate::spaces::db::get_space_by_address(
281281+ &state.db,
282282+ state.db_backend,
283283+ &uri.owner_did,
284284+ &uri.type_nsid,
285285+ &uri.skey,
286286+ )
287287+ .await
288288+ .map_err(|e| mlua::Error::runtime(format!("space lookup failed: {e}")))?;
289289+ let space = match space {
290290+ Some(s) => s,
291291+ None => return Ok(false),
292292+ };
293293+ let access =
294294+ crate::spaces::members::is_member(&state.db, state.db_backend, &space.id, &did)
295295+ .await
296296+ .map_err(|e| {
297297+ mlua::Error::runtime(format!("membership check failed: {e}"))
298298+ })?;
299299+ Ok(access.is_some())
300300+ }
301301+ })?;
302302+ spaces_table.set("is_member", is_member_fn)?;
303303+304304+ // atproto.spaces.get_access(space_uri, did) -> 'read' | 'write' | nil
305305+ let state_clone = state.clone();
306306+ let get_access_fn =
307307+ lua.create_async_function(move |_lua, (space_uri, did): (String, String)| {
308308+ let state = state_clone.clone();
309309+ async move {
310310+ if !crate::feature_flags::is_enabled(
311311+ &state.db,
312312+ crate::feature_flags::FeatureFlag::SPACES_ENABLED,
313313+ state.db_backend,
314314+ )
315315+ .await
316316+ {
317317+ return Err(mlua::Error::runtime("spaces feature is not enabled"));
318318+ }
319319+ let uri = crate::spaces::SpaceUri::parse(&space_uri)
320320+ .map_err(|e| mlua::Error::runtime(format!("invalid space URI: {e}")))?;
321321+ let space = crate::spaces::db::get_space_by_address(
322322+ &state.db,
323323+ state.db_backend,
324324+ &uri.owner_did,
325325+ &uri.type_nsid,
326326+ &uri.skey,
327327+ )
328328+ .await
329329+ .map_err(|e| mlua::Error::runtime(format!("space lookup failed: {e}")))?;
330330+ let space = match space {
331331+ Some(s) => s,
332332+ None => return Ok(None),
333333+ };
334334+ let access =
335335+ crate::spaces::members::is_member(&state.db, state.db_backend, &space.id, &did)
336336+ .await
337337+ .map_err(|e| {
338338+ mlua::Error::runtime(format!("membership check failed: {e}"))
339339+ })?;
340340+ Ok(access.map(|a| a.as_str().to_string()))
341341+ }
342342+ })?;
343343+ spaces_table.set("get_access", get_access_fn)?;
344344+345345+ // atproto.spaces.list_members(space_uri) -> array of { did, access }
346346+ let state_clone = state.clone();
347347+ let list_members_fn = lua.create_async_function(move |lua, space_uri: String| {
348348+ let state = state_clone.clone();
349349+ async move {
350350+ if !crate::feature_flags::is_enabled(
351351+ &state.db,
352352+ crate::feature_flags::FeatureFlag::SPACES_ENABLED,
353353+ state.db_backend,
354354+ )
355355+ .await
356356+ {
357357+ return Err(mlua::Error::runtime("spaces feature is not enabled"));
358358+ }
359359+ let uri = crate::spaces::SpaceUri::parse(&space_uri)
360360+ .map_err(|e| mlua::Error::runtime(format!("invalid space URI: {e}")))?;
361361+ let space = crate::spaces::db::get_space_by_address(
362362+ &state.db,
363363+ state.db_backend,
364364+ &uri.owner_did,
365365+ &uri.type_nsid,
366366+ &uri.skey,
367367+ )
368368+ .await
369369+ .map_err(|e| mlua::Error::runtime(format!("space lookup failed: {e}")))?;
370370+ let space = match space {
371371+ Some(s) => s,
372372+ None => {
373373+ return Err(mlua::Error::runtime("space not found"));
374374+ }
375375+ };
376376+ let members =
377377+ crate::spaces::members::resolve_members(&state.db, state.db_backend, &space.id)
378378+ .await
379379+ .map_err(|e| mlua::Error::runtime(format!("member resolution failed: {e}")))?;
380380+381381+ let result = lua.create_table()?;
382382+ for (i, member) in members.iter().enumerate() {
383383+ let entry = lua.create_table()?;
384384+ entry.set("did", member.did.as_str())?;
385385+ entry.set("access", member.access.as_str())?;
386386+ result.set(i + 1, entry)?;
387387+ }
388388+ Ok(mlua::Value::Table(result))
389389+ }
390390+ })?;
391391+ spaces_table.set("list_members", list_members_fn)?;
392392+393393+ // atproto.spaces.query({ space_uri, collection, limit, cursor }) -> { records, cursor }
394394+ let state_clone = state.clone();
395395+ let query_fn = lua.create_async_function(move |lua, opts: mlua::Table| {
396396+ let state = state_clone.clone();
397397+ async move {
398398+ if !crate::feature_flags::is_enabled(
399399+ &state.db,
400400+ crate::feature_flags::FeatureFlag::SPACES_ENABLED,
401401+ state.db_backend,
402402+ )
403403+ .await
404404+ {
405405+ return Err(mlua::Error::runtime("spaces feature is not enabled"));
406406+ }
407407+ let space_uri: String = opts
408408+ .get("space_uri")
409409+ .map_err(|_| mlua::Error::runtime("space_uri is required"))?;
410410+ let collection: Option<String> = opts.get("collection").ok();
411411+ let limit: i64 = opts.get("limit").unwrap_or(50);
412412+ let cursor: Option<String> = opts.get("cursor").ok();
413413+414414+ let uri = crate::spaces::SpaceUri::parse(&space_uri)
415415+ .map_err(|e| mlua::Error::runtime(format!("invalid space URI: {e}")))?;
416416+ let space = crate::spaces::db::get_space_by_address(
417417+ &state.db,
418418+ state.db_backend,
419419+ &uri.owner_did,
420420+ &uri.type_nsid,
421421+ &uri.skey,
422422+ )
423423+ .await
424424+ .map_err(|e| mlua::Error::runtime(format!("space lookup failed: {e}")))?;
425425+ let space = match space {
426426+ Some(s) => s,
427427+ None => {
428428+ return Err(mlua::Error::runtime("space not found"));
429429+ }
430430+ };
431431+432432+ let records = crate::spaces::db::list_space_records(
433433+ &state.db,
434434+ state.db_backend,
435435+ &space.id,
436436+ collection.as_deref(),
437437+ limit.min(100),
438438+ cursor.as_deref(),
439439+ )
440440+ .await
441441+ .map_err(|e| mlua::Error::runtime(format!("record query failed: {e}")))?;
442442+443443+ let next_cursor = records.last().map(|r| r.indexed_at.clone());
444444+445445+ let result = lua.create_table()?;
446446+ let records_table = lua.create_table()?;
447447+ for (i, record) in records.iter().enumerate() {
448448+ let entry = lua.to_value(&serde_json::json!({
449449+ "uri": record.uri,
450450+ "collection": record.collection,
451451+ "rkey": record.rkey,
452452+ "record": record.record,
453453+ "cid": record.cid,
454454+ "authorDid": record.author_did,
455455+ }))?;
456456+ records_table.set(i + 1, entry)?;
457457+ }
458458+ result.set("records", records_table)?;
459459+ match next_cursor {
460460+ Some(c) => result.set("cursor", c)?,
461461+ None => result.set("cursor", mlua::Value::Nil)?,
462462+ }
463463+464464+ Ok(mlua::Value::Table(result))
465465+ }
466466+ })?;
467467+ spaces_table.set("query", query_fn)?;
468468+469469+ atproto_table.set("spaces", spaces_table)?;
470470+260471 lua.globals().set("atproto", atproto_table)?;
261472 Ok(())
262473}
···516727 "#;
517728 let result: bool = lua.load(chunk).eval_async().await.unwrap();
518729 assert!(!result);
730730+ }
731731+732732+ #[tokio::test]
733733+ async fn spaces_api_is_registered() {
734734+ let state = test_state_with_plc("");
735735+ let lua = mlua::Lua::new();
736736+ register_atproto_api(&lua, Arc::new(state), None).unwrap();
737737+738738+ let chunk = r#"
739739+ return type(atproto.spaces) == "table"
740740+ and type(atproto.spaces.is_member) == "function"
741741+ and type(atproto.spaces.get_access) == "function"
742742+ and type(atproto.spaces.list_members) == "function"
743743+ and type(atproto.spaces.query) == "function"
744744+ "#;
745745+ let result: bool = lua.load(chunk).eval_async().await.unwrap();
746746+ assert!(result);
519747 }
520748}
···3333}
34343535/// Execute a Lua script for a procedure endpoint.
3636+#[allow(clippy::too_many_arguments)]
3637pub async fn execute_procedure_script(
3738 state: &AppState,
3839 method: &str,
···4142 params: &std::collections::HashMap<String, Value>,
4243 lexicon: &ParsedLexicon,
4344 script: &str,
4545+ space_ctx: Option<&context::SpaceContext>,
4646+ delegate_did: Option<&str>,
4447) -> Result<Response, AppError> {
4548 let start = Instant::now();
4649 let backend = state.db_backend;
···251254 return Err(AppError::Internal(error_message));
252255 }
253256254254- if let Err(e) = record::register_record_api(&lua, state_arc, claims_arc, pds_auth_arc) {
257257+ if let Err(e) = record::register_record_api(
258258+ &lua,
259259+ state_arc,
260260+ claims_arc,
261261+ pds_auth_arc,
262262+ delegate_did.map(|s| s.to_string()),
263263+ ) {
255264 let error_message = format!("failed to register Record API: {e}");
256265 log_event(
257266 &state.db,
···275284 return Err(AppError::Internal(error_message));
276285 }
277286278278- if let Err(e) =
279279- context::set_procedure_context(&lua, method, input, params, claims.did(), collection)
280280- {
287287+ if let Err(e) = context::set_procedure_context(
288288+ &lua,
289289+ method,
290290+ input,
291291+ params,
292292+ claims.did(),
293293+ collection,
294294+ space_ctx,
295295+ delegate_did,
296296+ ) {
281297 let error_message = format!("failed to set context: {e}");
282298 log_event(
283299 &state.db,
···503519 lexicon: &ParsedLexicon,
504520 script: &str,
505521 claims: Option<&Claims>,
522522+ space_ctx: Option<&context::SpaceContext>,
506523) -> Result<Response, AppError> {
507524 let start = Instant::now();
508525 let backend = state.db_backend;
···632649 return Err(AppError::Internal(error_message));
633650 }
634651635635- if let Err(e) =
636636- context::set_query_context(&lua, method, params, collection, claims.map(|c| c.did()))
637637- {
652652+ if let Err(e) = context::set_query_context(
653653+ &lua,
654654+ method,
655655+ params,
656656+ collection,
657657+ claims.map(|c| c.did()),
658658+ space_ctx,
659659+ ) {
638660 let error_message = format!("failed to set context: {e}");
639661 log_event(
640662 &state.db,
+2
src/lua/mod.rs
···88mod tid;
99mod xrpc_api;
10101111+#[allow(unused_imports)]
1212+pub(crate) use context::SpaceContext;
1113pub(crate) use execute::{
1214 HookEvent, execute_hook_script, execute_procedure_script, execute_query_script, run_hook_once,
1315};
+23-3
src/lua/record.rs
···23232424/// Register the `Record` global constructor and static methods.
2525/// Only registered for procedure scripts (not queries).
2626+///
2727+/// When `delegate_did` is `Some`, record writes default to the delegate's repo
2828+/// instead of the caller's DID. Scripts can still override via `record:set_repo()`.
2629pub fn register_record_api(
2730 lua: &Lua,
2831 state: Arc<AppState>,
2932 claims: Arc<Claims>,
3033 pds_auth: Arc<PdsAuth>,
3434+ delegate_did: Option<String>,
3135) -> LuaResult<()> {
3236 // -- methods table (shared by all Record instances) --
3337 let methods = lua.create_table()?;
···3741 let state = state.clone();
3842 let claims = claims.clone();
3943 let pds_auth = pds_auth.clone();
4444+ let delegate_did = delegate_did.clone();
4045 let save_fn = lua.create_async_function(move |lua, this: mlua::Table| {
4146 let state = state.clone();
4247 let claims = claims.clone();
4348 let pds_auth = pds_auth.clone();
4949+ let delegate_did = delegate_did.clone();
4450 async move {
4551 let backend = state.db_backend;
4652 let collection: String = this.raw_get("_collection")?;
4753 let schema: mlua::Value = this.raw_get("_schema")?;
4854 let repo_override: Option<String> = this.raw_get("_repo_override")?;
4949- let repo = repo_override.as_deref().unwrap_or_else(|| claims.did());
5555+ let repo = repo_override
5656+ .as_deref()
5757+ .or(delegate_did.as_deref())
5858+ .unwrap_or_else(|| claims.did());
50595160 // Validate required fields against schema
5261 if let mlua::Value::Table(ref schema_table) = schema {
···207216 let state = state.clone();
208217 let claims = claims.clone();
209218 let pds_auth = pds_auth.clone();
219219+ let delegate_did = delegate_did.clone();
210220 let delete_fn = lua.create_async_function(move |_lua, this: mlua::Table| {
211221 let state = state.clone();
212222 let claims = claims.clone();
213223 let pds_auth = pds_auth.clone();
224224+ let delegate_did = delegate_did.clone();
214225 async move {
215226 let backend = state.db_backend;
216227 let uri: String = this.raw_get::<Option<String>>("_uri")?.ok_or_else(|| {
···218229 })?;
219230 let collection: String = this.raw_get("_collection")?;
220231 let repo_override: Option<String> = this.raw_get("_repo_override")?;
221221- let repo = repo_override.as_deref().unwrap_or_else(|| claims.did());
232232+ let repo = repo_override
233233+ .as_deref()
234234+ .or(delegate_did.as_deref())
235235+ .unwrap_or_else(|| claims.did());
222236223237 let rkey = uri
224238 .split('/')
···439453 let state = state.clone();
440454 let claims = claims.clone();
441455 let pds_auth = pds_auth.clone();
456456+ let delegate_did = delegate_did.clone();
442457 let save_all_fn =
443458 lua.create_async_function(move |lua, records_table: mlua::Table| {
444459 let state = state.clone();
445460 let claims = claims.clone();
446461 let pds_auth = pds_auth.clone();
462462+ let delegate_did = delegate_did.clone();
447463 async move {
448464 let backend = state.db_backend;
449465 // Extract save data from each record (sync)
···472488 let state = state.clone();
473489 let claims = claims.clone();
474490 let pds_auth = pds_auth.clone();
491491+ let delegate_did = delegate_did.clone();
475492 let collection = collection.clone();
476493 let existing_uri = existing_uri.clone();
477494 let rkey = rkey.clone();
478495 let repo_override = repo_override.clone();
479496 let data = data.clone();
480497 async move {
481481- let repo = repo_override.as_deref().unwrap_or_else(|| claims.did());
498498+ let repo = repo_override
499499+ .as_deref()
500500+ .or(delegate_did.as_deref())
501501+ .unwrap_or_else(|| claims.did());
482502 if let Some(ref uri) = existing_uri {
483503 let rkey = uri
484504 .split('/')