diff --git a/packages/docs/content/docs/api-reference/lua/atproto-api.md b/packages/docs/content/docs/api-reference/lua/atproto-api.md index 69ebcd6..5308d2f 100644 --- a/packages/docs/content/docs/api-reference/lua/atproto-api.md +++ b/packages/docs/content/docs/api-reference/lua/atproto-api.md @@ -242,6 +242,84 @@ if atproto.sign then end ``` +## atproto.spaces + +The `atproto.spaces` sub-table provides access to [Permissioned Spaces](../../experimental/spaces/index.md) from Lua scripts. Every function raises if the `spaces_enabled` feature flag is disabled. + +### Read-only functions + +These are available in all script contexts, including those that have no `caller_did` (anonymous queries and label scripts). + +```lua +atproto.spaces.is_member(space_uri, did) -- boolean +atproto.spaces.get_access(space_uri, did) -- 'read' | 'write' | nil +atproto.spaces.list_members(space_uri) -- [{ did, access }] +atproto.spaces.query({ space_uri, collection?, limit?, cursor? }) -- { records, cursor } +``` + +| Function | Parameters | Description | +| ------------- | ------------------------------------------------------- | --------------------------------------------------------------------- | +| `is_member` | `space_uri, did` | Whether `did` is a member of the space | +| `get_access` | `space_uri, did` | The member's access level, or `nil` if not a member | +| `list_members`| `space_uri` | All resolved members as `{ did, access }` | +| `query` | `{ space_uri, collection?, limit?, cursor? }` | List records in the space, optionally filtered by collection | + +### Write surface: handles + +Three functions return a `Space` userdata handle bound to the calling script's `caller_did`. They require `caller_did` to be set — it is present in authenticated queries, procedure scripts, record-event scripts (where it is the indexed record's author DID), and jobs. Label scripts and anonymous queries have no `caller_did`, so these raise `"this space operation requires an authenticated caller"`. + +```lua +atproto.spaces.get(space_uri) -- Space | nil +atproto.spaces.create{ type, skey, display_name?, description?, mint_policy?, app_access?, managing_app_did?, config? } -- Space +atproto.spaces.accept_invite{ token } -- Space +``` + +`atproto.spaces.get` does not itself require `caller_did` to look up the space, but the returned handle's write methods will still raise without one. + +### Space handle methods + +| Method | Signature | Description | +| --- | --- | --- | +| `write_record` | `space:write_record{ collection, record }` | Create a record with an auto-generated rkey — returns `{ uri, cid }` | +| `put_record` | `space:put_record{ collection, rkey, record, swap_cid? }` | Create or overwrite a record at a specific rkey — returns `{ uri, cid }` | +| `delete_record` | `space:delete_record{ collection, rkey, swap_cid? }` | Delete a record. Requires write membership *and* record authorship — the caller must be a current write-member of the space and the record's author — returns `true` | +| `add_member` | `space:add_member{ did, access?, is_delegation? }` | Add a member (space-admin only) — returns `{ did, access }` | +| `remove_member` | `space:remove_member{ did }` | Remove a member (space-admin only) — returns `true` | +| `members` | `space:members()` | List resolved members — returns `[{ did, access }]` | +| `is_member` | `space:is_member(did)` | Whether `did` is a member — returns `boolean` | +| `access` | `space:access(did)` | The member's access level, or `nil` — returns `'read' \| 'write' \| nil` | +| `update` | `space:update{ display_name?, description?, mint_policy?, app_access?, managing_app_did?, config? }` | Update space metadata (space-admin only) — returns `true` | +| `delete` | `space:delete()` | Delete the space (space-admin only) — returns `true` | +| `query` | `space:query{ collection?, limit?, cursor? }` | List records in the space — returns `{ records, cursor }` | +| `create_invite` | `space:create_invite{ access?, max_uses?, expires_at? }` | Create an invite token (space-admin only) — returns `{ invite_id, token, access, max_uses, expires_at }` | + +**Authorization:** writes act strictly as `caller_did`, using the same authorization the HTTP handlers enforce — a write-member for record operations, space-admin for membership/space/invite management. `delete_record` additionally requires record authorship: the caller must be both a current write-member and the record's author (`author_did == caller_did`). The HTTP handlers and the Lua bindings share the same `src/spaces/service.rs` service layer, so behavior can't drift between them. + +**`update` patch semantics:** for the nullable fields (`display_name`, `description`, `managing_app_did`), a string value sets the field, `false` clears it, and omitting the key leaves it unchanged. Lua has no way to represent "explicit null" versus "absent" in a table — a `nil` value simply removes the key — so `false` is used as the clear signal instead. + +### Examples + +```lua +-- Create a space, write a record, and add a member +local space = atproto.spaces.create{ type = "com.example.chat", skey = "general" } +space:write_record{ collection = "com.example.chat.message", record = { text = "hi" } } +space:add_member{ did = "did:plc:friend", access = "write" } +``` + +```lua +-- Look up an existing space and write to it if the caller is a member +local space = atproto.spaces.get("at://did:plc:abc123/space/com.example.forum/main") +if space and space:is_member(caller_did) then + space:write_record{ collection = "com.example.forum.post", record = { text = "hello" } } +end +``` + +```lua +-- Join a space via an invite token, then write as the new member +local space = atproto.spaces.accept_invite{ token = invite_token } +space:write_record{ collection = "com.example.chat.message", record = { text = "just joined!" } } +``` + ## atproto.verify_signature ```lua diff --git a/src/jobs/worker.rs b/src/jobs/worker.rs index 8abf2ee..6a6c91b 100644 --- a/src/jobs/worker.rs +++ b/src/jobs/worker.rs @@ -175,6 +175,14 @@ async fn execute_job(state: &AppState, job: &super::Job) { let _ = db::set_error(state, &job.id, &format!("atproto api: {e}")).await; return; } + if let Err(e) = crate::lua::spaces_api::register_spaces_write_api( + &lua, + state_arc.clone(), + Some(&job.created_by), + ) { + let _ = db::set_error(state, &job.id, &format!("spaces write api: {e}")).await; + return; + } if let (Some(c), Some(p)) = (&claims, &pds_auth_arc) && let Err(e) = crate::lua::atproto_api::register_atproto_blob_api( &lua, diff --git a/src/lua/execute.rs b/src/lua/execute.rs index b275cd4..6c99aab 100644 --- a/src/lua/execute.rs +++ b/src/lua/execute.rs @@ -237,6 +237,34 @@ pub async fn execute_procedure_script( return Err(AppError::Internal(error_message)); } + if let Err(e) = crate::lua::spaces_api::register_spaces_write_api( + &lua, + state_arc.clone(), + Some(claims.did()), + ) { + let error_message = format!("failed to register spaces write API: {e}"); + log_event( + &state.db, + EventLog { + event_type: "script.error".to_string(), + severity: Severity::Error, + actor_did: Some(claims.did().to_string()), + subject: Some(method.to_string()), + detail: serde_json::json!({ + "error": error_message, + "script_source": script_source, + "input": input_json, + "caller_did": claims.did(), + "method": method, + "duration_ms": start.elapsed().as_millis() as u64, + }), + }, + backend, + ) + .await; + return Err(AppError::Internal(error_message)); + } + if let Some(ref pds_auth) = pds_auth_arc && let Err(e) = atproto_api::register_atproto_blob_api( &lua, @@ -730,6 +758,32 @@ pub async fn execute_query_script( return Err(AppError::Internal(error_message)); } + if let Err(e) = crate::lua::spaces_api::register_spaces_write_api( + &lua, + state_arc.clone(), + claims.map(|c| c.did()), + ) { + let error_message = format!("failed to register spaces write API: {e}"); + log_event( + &state.db, + EventLog { + event_type: "script.error".to_string(), + severity: Severity::Error, + actor_did: None, + subject: Some(method.to_string()), + detail: serde_json::json!({ + "error": error_message, + "script_source": script_source, + "method": method, + "duration_ms": start.elapsed().as_millis() as u64, + }), + }, + backend, + ) + .await; + return Err(AppError::Internal(error_message)); + } + // Register the Record API in no-auth mode. Queries don't have a PDS // auth context — the local-only methods (Record.load, :save_local, // :delete_local, Record.delete_local) work; PDS-touching variants diff --git a/src/lua/mod.rs b/src/lua/mod.rs index 5e04b2a..861c522 100644 --- a/src/lua/mod.rs +++ b/src/lua/mod.rs @@ -7,6 +7,7 @@ pub(crate) mod jobs_api; pub mod record; pub(crate) mod sandbox; pub mod scripts; +pub(crate) mod spaces_api; pub(crate) mod tid; pub(crate) mod xrpc_api; diff --git a/src/lua/scripts.rs b/src/lua/scripts.rs index c60f491..f8d0182 100644 --- a/src/lua/scripts.rs +++ b/src/lua/scripts.rs @@ -749,6 +749,8 @@ fn register_default_apis( .map_err(|e| format!("xrpc api: {e}"))?; atproto_api::register_atproto_api(lua, state.clone(), None) .map_err(|e| format!("atproto api: {e}"))?; + crate::lua::spaces_api::register_spaces_write_api(lua, state.clone(), caller_did) + .map_err(|e| format!("spaces write api: {e}"))?; super::jobs_api::register_jobs_api(lua, state.clone(), caller_did.map(String::from)) .map_err(|e| format!("jobs api: {e}"))?; record::register_record_api_no_auth(lua, state.clone()) diff --git a/src/lua/spaces_api.rs b/src/lua/spaces_api.rs new file mode 100644 index 0000000..33a2d49 --- /dev/null +++ b/src/lua/spaces_api.rs @@ -0,0 +1,1919 @@ +use mlua::{Lua, LuaSerdeExt, Result as LuaResult}; +use std::sync::Arc; + +use crate::AppState; +use crate::spaces::{SpaceUri, service}; + +pub const SPACES_NO_CALLER_MSG: &str = + "this space operation requires an authenticated caller (caller_did is not set in this context)"; + +async fn spaces_enabled(state: &AppState) -> bool { + crate::feature_flags::is_enabled( + &state.db, + crate::feature_flags::FeatureFlag::SPACES_ENABLED, + state.db_backend, + ) + .await +} + +pub(crate) struct LuaSpace { + state: Arc, + /// 4-segment space URI: at://{did}/space/{type}/{skey} + space_uri: String, + caller_did: Option, +} + +impl LuaSpace { + fn require_caller(&self) -> LuaResult { + self.caller_did + .clone() + .ok_or_else(|| mlua::Error::runtime(SPACES_NO_CALLER_MSG)) + } +} + +impl mlua::UserData for LuaSpace { + fn add_fields>(fields: &mut F) { + fields.add_field_method_get("uri", |_, this| Ok(this.space_uri.clone())); + } + + fn add_methods>(methods: &mut M) { + // space:write_record{ collection, record } -> { uri, cid } + methods.add_async_method("write_record", |lua, this, opts: mlua::Table| async move { + let state = this.state.clone(); + let space_uri = this.space_uri.clone(); + let did = this.require_caller()?; + if !spaces_enabled(&state).await { + return Err(mlua::Error::runtime("spaces feature is not enabled")); + } + let collection: String = opts + .get("collection") + .map_err(|_| mlua::Error::runtime("write_record: collection is required"))?; + let record_val: mlua::Value = opts.get("record")?; + if record_val.is_nil() { + return Err(mlua::Error::runtime("write_record: record is required")); + } + let record: serde_json::Value = lua.from_value(record_val)?; + let (uri, cid) = + service::create_record(&state, &did, None, &space_uri, &collection, record) + .await + .map_err(|e| mlua::Error::runtime(format!("write_record: {e}")))?; + let result = lua.create_table()?; + result.set("uri", uri)?; + result.set("cid", cid)?; + Ok(mlua::Value::Table(result)) + }); + + // space:put_record{ collection, rkey, record, swap_cid? } -> { uri, cid } + methods.add_async_method("put_record", |lua, this, opts: mlua::Table| async move { + let state = this.state.clone(); + let space_uri = this.space_uri.clone(); + let did = this.require_caller()?; + if !spaces_enabled(&state).await { + return Err(mlua::Error::runtime("spaces feature is not enabled")); + } + let collection: String = opts + .get("collection") + .map_err(|_| mlua::Error::runtime("put_record: collection is required"))?; + let rkey: String = opts + .get("rkey") + .map_err(|_| mlua::Error::runtime("put_record: rkey is required"))?; + let record_val: mlua::Value = opts.get("record")?; + if record_val.is_nil() { + return Err(mlua::Error::runtime("put_record: record is required")); + } + let record: serde_json::Value = lua.from_value(record_val)?; + let swap_cid: Option = opts.get("swap_cid").ok(); + let (uri, cid) = service::put_record( + &state, + &did, + None, + &space_uri, + &collection, + &rkey, + record, + swap_cid, + ) + .await + .map_err(|e| mlua::Error::runtime(format!("put_record: {e}")))?; + let result = lua.create_table()?; + result.set("uri", uri)?; + result.set("cid", cid)?; + Ok(mlua::Value::Table(result)) + }); + + // space:delete_record{ collection, rkey, swap_cid? } -> true + methods.add_async_method( + "delete_record", + |_lua, this, opts: mlua::Table| async move { + let state = this.state.clone(); + let space_uri = this.space_uri.clone(); + let did = this.require_caller()?; + if !spaces_enabled(&state).await { + return Err(mlua::Error::runtime("spaces feature is not enabled")); + } + let collection: String = opts + .get("collection") + .map_err(|_| mlua::Error::runtime("delete_record: collection is required"))?; + let rkey: String = opts + .get("rkey") + .map_err(|_| mlua::Error::runtime("delete_record: rkey is required"))?; + let swap_cid: Option = opts.get("swap_cid").ok(); + service::delete_record(&state, &did, &space_uri, &collection, &rkey, swap_cid) + .await + .map_err(|e| mlua::Error::runtime(format!("delete_record: {e}")))?; + Ok(true) + }, + ); + + // space:add_member{ did, access?, is_delegation? } -> { did, access } + methods.add_async_method("add_member", |lua, this, opts: mlua::Table| async move { + let state = this.state.clone(); + let space_uri = this.space_uri.clone(); + let actor = this.require_caller()?; + if !spaces_enabled(&state).await { + return Err(mlua::Error::runtime("spaces feature is not enabled")); + } + let member_did: String = opts + .get("did") + .map_err(|_| mlua::Error::runtime("add_member: did is required"))?; + let access: Option = + match opts.get::>("access").ok().flatten() { + Some(s) => { + Some(crate::spaces::types::SpaceAccess::parse(&s).ok_or_else(|| { + mlua::Error::runtime(format!("add_member: invalid access '{s}'")) + })?) + } + None => None, + }; + let is_delegation: Option = opts.get("is_delegation").ok(); + let member = service::add_member( + &state, + &actor, + &space_uri, + &member_did, + access, + is_delegation, + ) + .await + .map_err(|e| mlua::Error::runtime(format!("add_member: {e}")))?; + let result = lua.create_table()?; + result.set("did", member.did)?; + result.set("access", member.access.as_str())?; + Ok(mlua::Value::Table(result)) + }); + + // space:remove_member{ did } -> true + methods.add_async_method( + "remove_member", + |_lua, this, opts: mlua::Table| async move { + let state = this.state.clone(); + let space_uri = this.space_uri.clone(); + let actor = this.require_caller()?; + if !spaces_enabled(&state).await { + return Err(mlua::Error::runtime("spaces feature is not enabled")); + } + let member_did: String = opts + .get("did") + .map_err(|_| mlua::Error::runtime("remove_member: did is required"))?; + service::remove_member(&state, &actor, &space_uri, &member_did) + .await + .map_err(|e| mlua::Error::runtime(format!("remove_member: {e}")))?; + Ok(true) + }, + ); + + // space:members() -> [{ did, access }] + methods.add_async_method("members", |lua, this, ()| async move { + let state = this.state.clone(); + let space_uri = this.space_uri.clone(); + if !spaces_enabled(&state).await { + return Err(mlua::Error::runtime("spaces feature is not enabled")); + } + let space = service::resolve_space(&state, &space_uri) + .await + .map_err(|e| mlua::Error::runtime(format!("members: {e}")))?; + let members = + crate::spaces::members::resolve_members(&state.db, state.db_backend, &space.id) + .await + .map_err(|e| mlua::Error::runtime(format!("members: {e}")))?; + let result = lua.create_table()?; + for (i, m) in members.iter().enumerate() { + let entry = lua.create_table()?; + entry.set("did", m.did.as_str())?; + entry.set("access", m.access.as_str())?; + result.set(i + 1, entry)?; + } + Ok(mlua::Value::Table(result)) + }); + + // space:is_member(did) -> bool ; space:access(did) -> 'read'|'write'|nil + methods.add_async_method("is_member", |_lua, this, did: String| async move { + let state = this.state.clone(); + let space_uri = this.space_uri.clone(); + if !spaces_enabled(&state).await { + return Err(mlua::Error::runtime("spaces feature is not enabled")); + } + let space = service::resolve_space(&state, &space_uri) + .await + .map_err(|e| mlua::Error::runtime(format!("is_member: {e}")))?; + let access = + crate::spaces::members::is_member(&state.db, state.db_backend, &space.id, &did) + .await + .map_err(|e| mlua::Error::runtime(format!("is_member: {e}")))?; + Ok(access.is_some()) + }); + methods.add_async_method("access", |_lua, this, did: String| async move { + let state = this.state.clone(); + let space_uri = this.space_uri.clone(); + if !spaces_enabled(&state).await { + return Err(mlua::Error::runtime("spaces feature is not enabled")); + } + let space = service::resolve_space(&state, &space_uri) + .await + .map_err(|e| mlua::Error::runtime(format!("access: {e}")))?; + let access = + crate::spaces::members::is_member(&state.db, state.db_backend, &space.id, &did) + .await + .map_err(|e| mlua::Error::runtime(format!("access: {e}")))?; + Ok(access.map(|a| a.as_str().to_string())) + }); + + // space:update{ display_name?, description?, mint_policy?, app_access?, managing_app_did?, config? } -> true + // For nullable patch fields, a Lua `false` clears (sets to nil); omitting leaves unchanged. + methods.add_async_method("update", |lua, this, opts: mlua::Table| async move { + let state = this.state.clone(); + let space_uri = this.space_uri.clone(); + let actor = this.require_caller()?; + if !spaces_enabled(&state).await { + return Err(mlua::Error::runtime("spaces feature is not enabled")); + } + // Patch semantics: key present with string => Some(Some(s)); key present and false => Some(None) (clear); key absent => None. + fn nullable(opts: &mlua::Table, key: &str) -> mlua::Result>> { + match opts.get::(key)? { + mlua::Value::Nil => Ok(None), + mlua::Value::Boolean(false) => Ok(Some(None)), + mlua::Value::String(s) => Ok(Some(Some(s.to_str()?.to_owned()))), + _ => Err(mlua::Error::runtime(format!( + "update: {key} must be a string or false" + ))), + } + } + let display_name = nullable(&opts, "display_name")?; + let description = nullable(&opts, "description")?; + let managing_app_did = nullable(&opts, "managing_app_did")?; + let mint_policy: Option = + match opts.get::>("mint_policy").ok().flatten() { + Some(s) => { + Some(crate::spaces::types::MintPolicy::parse(&s).ok_or_else(|| { + mlua::Error::runtime(format!("update: invalid mint_policy '{s}'")) + })?) + } + None => None, + }; + let app_access: Option = + match opts.get::("app_access") { + Ok(mlua::Value::Nil) | Err(_) => None, + Ok(v) => Some(lua.from_value(v)?), + }; + let config: Option = + match opts.get::("config") { + Ok(mlua::Value::Nil) | Err(_) => None, + Ok(v) => Some(lua.from_value(v)?), + }; + service::update_space( + &state, + &actor, + &space_uri, + display_name, + description, + mint_policy, + app_access, + managing_app_did, + config, + ) + .await + .map_err(|e| mlua::Error::runtime(format!("update: {e}")))?; + Ok(true) + }); + + // space:delete() -> true + methods.add_async_method("delete", |_lua, this, ()| async move { + let state = this.state.clone(); + let space_uri = this.space_uri.clone(); + let actor = this.require_caller()?; + if !spaces_enabled(&state).await { + return Err(mlua::Error::runtime("spaces feature is not enabled")); + } + service::delete_space(&state, &actor, &space_uri) + .await + .map_err(|e| mlua::Error::runtime(format!("delete: {e}")))?; + Ok(true) + }); + + // space:query{ collection?, limit?, cursor? } -> { records, cursor } + methods.add_async_method("query", |lua, this, opts: mlua::Table| async move { + let state = this.state.clone(); + let space_uri = this.space_uri.clone(); + if !spaces_enabled(&state).await { + return Err(mlua::Error::runtime("spaces feature is not enabled")); + } + let space = service::resolve_space(&state, &space_uri) + .await + .map_err(|e| mlua::Error::runtime(format!("query: {e}")))?; + let collection: Option = opts.get("collection").ok(); + let limit: i64 = opts.get("limit").unwrap_or(50); + let cursor: Option = opts.get("cursor").ok(); + let (records, next_cursor) = crate::spaces::db::list_space_records( + &state.db, + state.db_backend, + &space.id, + None, + collection.as_deref(), + limit.min(100), + cursor.as_deref(), + false, + ) + .await + .map_err(|e| mlua::Error::runtime(format!("query: {e}")))?; + let result = lua.create_table()?; + let records_table = lua.create_table()?; + for (i, r) in records.iter().enumerate() { + let entry = lua.to_value(&serde_json::json!({ + "uri": r.uri, "collection": r.collection, "rkey": r.rkey, + "record": r.record, "cid": r.cid, "authorDid": r.author_did, + }))?; + records_table.set(i + 1, entry)?; + } + result.set("records", records_table)?; + match next_cursor { + Some(c) => result.set("cursor", c)?, + None => result.set("cursor", mlua::Value::Nil)?, + } + Ok(mlua::Value::Table(result)) + }); + + // space:create_invite{ access?, max_uses?, expires_at? } -> { invite_id, token, access, max_uses, expires_at } + methods.add_async_method("create_invite", |lua, this, opts: mlua::Table| async move { + let state = this.state.clone(); + let space_uri = this.space_uri.clone(); + let actor = this.require_caller()?; + if !spaces_enabled(&state).await { + return Err(mlua::Error::runtime("spaces feature is not enabled")); + } + let access: Option = + match opts.get::>("access").ok().flatten() { + Some(s) => { + Some(crate::spaces::types::SpaceAccess::parse(&s).ok_or_else(|| { + mlua::Error::runtime(format!("create_invite: invalid access '{s}'")) + })?) + } + None => None, + }; + let max_uses: Option = opts.get("max_uses").ok(); + let expires_at: Option = opts.get("expires_at").ok(); + let (invite, token) = + service::create_invite(&state, &actor, &space_uri, access, max_uses, expires_at) + .await + .map_err(|e| mlua::Error::runtime(format!("create_invite: {e}")))?; + let result = lua.create_table()?; + result.set("invite_id", invite.id)?; + result.set("token", token)?; + result.set("access", invite.access.as_str())?; + result.set("max_uses", invite.max_uses)?; + result.set("expires_at", invite.expires_at)?; + Ok(mlua::Value::Table(result)) + }); + } +} + +/// Attach write/handle methods to the already-registered `atproto.spaces` table. +/// Must be called AFTER `register_atproto_api` (which creates `atproto.spaces`). +pub fn register_spaces_write_api( + lua: &Lua, + state: Arc, + caller_did: Option<&str>, +) -> LuaResult<()> { + let atproto: mlua::Table = lua.globals().get("atproto")?; + let spaces: mlua::Table = atproto.get("spaces")?; + let caller_did = caller_did.map(|s| s.to_string()); + + // atproto.spaces.get(space_uri) -> LuaSpace | nil + let state_clone = state.clone(); + let caller_clone = caller_did.clone(); + let get_fn = lua.create_async_function(move |lua, space_uri: String| { + let state = state_clone.clone(); + let caller_did = caller_clone.clone(); + async move { + if !spaces_enabled(&state).await { + return Err(mlua::Error::runtime("spaces feature is not enabled")); + } + let parsed = SpaceUri::parse(&space_uri) + .map_err(|e| mlua::Error::runtime(format!("invalid space URI: {e}")))?; + let space = service::resolve_space(&state, &parsed.space_uri()).await; + match space { + Ok(s) => { + let handle = LuaSpace { + state: state.clone(), + space_uri: format!("at://{}/space/{}/{}", s.did, s.type_nsid, s.skey), + caller_did, + }; + Ok(mlua::Value::UserData(lua.create_userdata(handle)?)) + } + Err(crate::error::AppError::NotFound(_)) => Ok(mlua::Value::Nil), + Err(e) => Err(mlua::Error::runtime(format!("space lookup failed: {e}"))), + } + } + })?; + spaces.set("get", get_fn)?; + + // atproto.spaces.create{ type, skey, display_name?, description?, mint_policy?, app_access?, managing_app_did?, config? } -> LuaSpace + let state_clone = state.clone(); + let caller_clone = caller_did.clone(); + let create_fn = lua.create_async_function(move |lua, opts: mlua::Table| { + let state = state_clone.clone(); + let caller_did = caller_clone.clone(); + async move { + if !spaces_enabled(&state).await { + return Err(mlua::Error::runtime("spaces feature is not enabled")); + } + let did = caller_did.ok_or_else(|| mlua::Error::runtime(SPACES_NO_CALLER_MSG))?; + let type_nsid: String = opts + .get("type") + .map_err(|_| mlua::Error::runtime("create: type is required"))?; + let skey: String = opts + .get("skey") + .map_err(|_| mlua::Error::runtime("create: skey is required"))?; + let display_name: Option = opts.get("display_name").ok(); + let description: Option = opts.get("description").ok(); + let mint_policy: Option = + match opts.get::>("mint_policy").ok().flatten() { + Some(s) => { + Some(crate::spaces::types::MintPolicy::parse(&s).ok_or_else(|| { + mlua::Error::runtime(format!("create: invalid mint_policy '{s}'")) + })?) + } + None => None, + }; + let managing_app_did: Option = opts.get("managing_app_did").ok(); + // app_access and config accept structured tables; deserialize via serde. + let app_access: Option = + match opts.get::("app_access") { + Ok(mlua::Value::Nil) | Err(_) => None, + Ok(v) => Some(lua.from_value(v)?), + }; + let config: Option = + match opts.get::("config") { + Ok(mlua::Value::Nil) | Err(_) => None, + Ok(v) => Some(lua.from_value(v)?), + }; + let space = service::create_space( + &state, + &did, + &type_nsid, + &skey, + display_name, + description, + mint_policy, + app_access, + managing_app_did, + config, + ) + .await + .map_err(|e| mlua::Error::runtime(format!("create: {e}")))?; + let handle = LuaSpace { + state: state.clone(), + space_uri: format!( + "at://{}/space/{}/{}", + space.did, space.type_nsid, space.skey + ), + caller_did: Some(did), + }; + Ok(mlua::Value::UserData(lua.create_userdata(handle)?)) + } + })?; + spaces.set("create", create_fn)?; + + // atproto.spaces.accept_invite{ token } -> LuaSpace + let state_clone = state.clone(); + let caller_clone = caller_did.clone(); + let accept_fn = lua.create_async_function(move |lua, opts: mlua::Table| { + let state = state_clone.clone(); + let caller_did = caller_clone.clone(); + async move { + if !spaces_enabled(&state).await { + return Err(mlua::Error::runtime("spaces feature is not enabled")); + } + let did = caller_did.ok_or_else(|| mlua::Error::runtime(SPACES_NO_CALLER_MSG))?; + let token: String = opts + .get("token") + .map_err(|_| mlua::Error::runtime("accept_invite: token is required"))?; + let (space_uri, _access) = service::accept_invite(&state, &did, &token) + .await + .map_err(|e| mlua::Error::runtime(format!("accept_invite: {e}")))?; + let handle = LuaSpace { + state: state.clone(), + space_uri, + caller_did: Some(did), + }; + Ok(mlua::Value::UserData(lua.create_userdata(handle)?)) + } + })?; + spaces.set("accept_invite", accept_fn)?; + + #[cfg(test)] + { + // Test-only constructor that builds a handle without a DB lookup. + let state_clone = state.clone(); + let caller_clone = caller_did.clone(); + let test_handle = lua.create_function(move |lua, space_uri: String| { + let handle = LuaSpace { + state: state_clone.clone(), + space_uri, + caller_did: caller_clone.clone(), + }; + lua.create_userdata(handle) + })?; + spaces.set("__test_handle", test_handle)?; + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use crate::AppState; + use crate::config::Config; + use crate::db::DatabaseBackend; + use crate::lexicon::LexiconRegistry; + use serial_test::serial; + use tokio::sync::watch; + + fn test_state_with_plc(plc_url: &str) -> AppState { + let config = Config { + host: "127.0.0.1".into(), + port: 3000, + database_url: String::new(), + database_backend: crate::db::DatabaseBackend::Sqlite, + public_url: String::new(), + session_secret: "test-secret".into(), + jetstream_url: String::new(), + relay_url: String::new(), + plc_url: plc_url.to_string(), + static_dir: String::new(), + base_path: None, + event_log_retention_days: 30, + app_name: None, + logo_uri: None, + tos_uri: None, + policy_uri: None, + token_encryption_key: None, + default_rate_limit_capacity: 100, + default_rate_limit_refill_rate: 2.0, + }; + let (tx, _) = watch::channel(vec![]); + let (labeler_tx, _) = watch::channel(()); + sqlx::any::install_default_drivers(); + let test_db = sqlx::AnyPool::connect_lazy("sqlite::memory:").unwrap(); + let atrium_http = std::sync::Arc::new(atrium_oauth::DefaultHttpClient::default()); + let did_resolver = atrium_identity::did::CommonDidResolver::new( + atrium_identity::did::CommonDidResolverConfig { + plc_directory_url: "https://plc.directory".into(), + http_client: std::sync::Arc::clone(&atrium_http), + }, + ); + let handle_resolver = atrium_identity::handle::AtprotoHandleResolver::new( + atrium_identity::handle::AtprotoHandleResolverConfig { + dns_txt_resolver: crate::dns::NativeDnsResolver::new(), + http_client: atrium_http, + }, + ); + let oauth = atrium_oauth::OAuthClient::new(atrium_oauth::OAuthClientConfig { + client_metadata: atrium_oauth::AtprotoLocalhostClientMetadata { + redirect_uris: Some(vec!["http://127.0.0.1:0/auth/callback".into()]), + scopes: Some(vec![atrium_oauth::Scope::Known( + atrium_oauth::KnownScope::Atproto, + )]), + }, + keys: None, + state_store: crate::auth::oauth_store::DbStateStore::new( + test_db.clone(), + crate::db::DatabaseBackend::Sqlite, + ), + session_store: crate::auth::oauth_store::DbSessionStore::new( + test_db.clone(), + crate::db::DatabaseBackend::Sqlite, + ), + resolver: atrium_oauth::OAuthResolverConfig { + did_resolver, + handle_resolver, + authorization_server_metadata: Default::default(), + protected_resource_metadata: Default::default(), + }, + }) + .expect("Failed to create test OAuth client"); + AppState { + config, + http: reqwest::Client::new(), + db: test_db.clone(), + backfill_db: test_db.clone(), + db_backend: DatabaseBackend::Sqlite, + domain_cache: crate::domain::DomainCache::new(), + lexicons: LexiconRegistry::new(), + collections_tx: tx, + labeler_subscriptions_tx: labeler_tx, + rate_limiter: crate::rate_limit::RateLimiter::new( + crate::rate_limit::RateLimitDefaults { + query_cost: 1, + procedure_cost: 1, + proxy_cost: 1, + }, + ), + oauth: std::sync::Arc::new(crate::auth::OAuthClientRegistry::new(std::sync::Arc::new( + oauth, + ))), + oauth_state_store: crate::auth::oauth_store::DbStateStore::new( + test_db.clone(), + crate::db::DatabaseBackend::Sqlite, + ), + cookie_key: axum_extra::extract::cookie::Key::derive_from( + b"test-secret-for-tests-only-not-production", + ), + plugin_registry: std::sync::Arc::new(crate::plugin::PluginRegistry::new()), + wasm_runtime: std::sync::Arc::new( + crate::plugin::WasmRuntime::new().expect("wasm runtime"), + ), + attestation_signer: None, + official_registry: std::sync::Arc::new(tokio::sync::RwLock::new( + crate::plugin::official_registry::OfficialRegistryState::default(), + )), + official_registry_config: crate::plugin::official_registry::RegistryConfig::production( + ), + proxy_config: std::sync::Arc::new(arc_swap::ArcSwap::new(std::sync::Arc::new( + crate::proxy_config::ProxyConfig::default(), + ))), + backfill_events_tx: tokio::sync::broadcast::channel(16).0, + verbose_event_logging: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), + } + } + + #[tokio::test] + async fn spaces_write_api_is_registered() { + let state = test_state_with_plc(""); + let lua = mlua::Lua::new(); + crate::lua::atproto_api::register_atproto_api( + &lua, + std::sync::Arc::new(state.clone()), + Some("did:plc:x"), + ) + .unwrap(); + super::register_spaces_write_api(&lua, std::sync::Arc::new(state), Some("did:plc:x")) + .unwrap(); + let chunk = r#" + return type(atproto.spaces.get) == "function" + and type(atproto.spaces.create) == "function" + and type(atproto.spaces.accept_invite) == "function" + "#; + let ok: bool = lua.load(chunk).eval_async().await.unwrap(); + assert!(ok); + } + + #[tokio::test] + async fn write_record_raises_without_caller() { + let state = test_state_with_plc(""); + let lua = mlua::Lua::new(); + crate::lua::atproto_api::register_atproto_api( + &lua, + std::sync::Arc::new(state.clone()), + None, + ) + .unwrap(); + super::register_spaces_write_api(&lua, std::sync::Arc::new(state), None).unwrap(); + + // Build a handle directly (get() would need a DB); force the no-caller path. + let chunk = r#" + local space = atproto.spaces.__test_handle("at://did:plc:x/space/com.example/general") + local ok, err = pcall(function() + return space:write_record{ collection = "com.example.item", record = { text = "hi" } } + end) + return tostring(err) + "#; + let err: String = lua.load(chunk).eval_async().await.unwrap(); + // `err` is a Rust String here; assert on it with Rust string methods. + assert!( + err.contains("caller"), + "expected a no-caller error, got: {err}" + ); + } + + // ----------------------------------------------------------------------- + // DB-backed integration test (Postgres via TEST_DATABASE_URL). + // + // Mirrors the skip idiom used by `spaces::service`'s tests: when + // `TEST_DATABASE_URL` isn't set, the test returns early instead of + // failing, so `cargo test --lib` stays DB-free. Run for real via: + // `TEST_DATABASE_URL=postgres://... cargo test -p happyview spaces_write_lua` + // ----------------------------------------------------------------------- + macro_rules! require_test_db { + () => { + if std::env::var("TEST_DATABASE_URL").is_err() { + eprintln!("skipped (TEST_DATABASE_URL not set)"); + return; + } + }; + } + + /// Build an `AppState` backed by a migrated, empty Postgres database + /// (`TEST_DATABASE_URL`). Callers must have already checked + /// `require_test_db!()`. + async fn db_test_state() -> AppState { + let url = std::env::var("TEST_DATABASE_URL") + .expect("TEST_DATABASE_URL must be set for spaces_api DB integration tests"); + let backend = DatabaseBackend::from_url(&url); + let pool = crate::db::connect(&url, backend).await; + + let config = Config { + host: "127.0.0.1".into(), + port: 0, + database_url: String::new(), + database_backend: backend, + public_url: String::new(), + session_secret: "test-secret".into(), + jetstream_url: String::new(), + relay_url: String::new(), + plc_url: String::new(), + static_dir: String::new(), + base_path: None, + event_log_retention_days: 30, + app_name: None, + logo_uri: None, + tos_uri: None, + policy_uri: None, + token_encryption_key: None, + default_rate_limit_capacity: 100, + default_rate_limit_refill_rate: 2.0, + }; + let (collections_tx, _) = watch::channel(vec![]); + let (labeler_subscriptions_tx, _) = watch::channel(()); + + let atrium_http = std::sync::Arc::new(atrium_oauth::DefaultHttpClient::default()); + let did_resolver = atrium_identity::did::CommonDidResolver::new( + atrium_identity::did::CommonDidResolverConfig { + plc_directory_url: "https://plc.directory".into(), + http_client: std::sync::Arc::clone(&atrium_http), + }, + ); + let handle_resolver = atrium_identity::handle::AtprotoHandleResolver::new( + atrium_identity::handle::AtprotoHandleResolverConfig { + dns_txt_resolver: crate::dns::NativeDnsResolver::new(), + http_client: atrium_http, + }, + ); + let oauth = atrium_oauth::OAuthClient::new(atrium_oauth::OAuthClientConfig { + client_metadata: atrium_oauth::AtprotoLocalhostClientMetadata { + redirect_uris: Some(vec!["http://127.0.0.1:0/auth/callback".into()]), + scopes: Some(vec![atrium_oauth::Scope::Known( + atrium_oauth::KnownScope::Atproto, + )]), + }, + keys: None, + state_store: crate::auth::oauth_store::DbStateStore::new(pool.clone(), backend), + session_store: crate::auth::oauth_store::DbSessionStore::new(pool.clone(), backend), + resolver: atrium_oauth::OAuthResolverConfig { + did_resolver, + handle_resolver, + authorization_server_metadata: Default::default(), + protected_resource_metadata: Default::default(), + }, + }) + .expect("Failed to create test OAuth client"); + + AppState { + config, + http: reqwest::Client::new(), + db: pool.clone(), + backfill_db: pool.clone(), + db_backend: backend, + domain_cache: crate::domain::DomainCache::new(), + lexicons: LexiconRegistry::new(), + collections_tx, + labeler_subscriptions_tx, + rate_limiter: crate::rate_limit::RateLimiter::new( + crate::rate_limit::RateLimitDefaults { + query_cost: 1, + procedure_cost: 1, + proxy_cost: 1, + }, + ), + oauth: std::sync::Arc::new(crate::auth::OAuthClientRegistry::new(std::sync::Arc::new( + oauth, + ))), + oauth_state_store: crate::auth::oauth_store::DbStateStore::new(pool.clone(), backend), + cookie_key: axum_extra::extract::cookie::Key::derive_from( + b"test-secret-for-tests-only-not-production", + ), + plugin_registry: std::sync::Arc::new(crate::plugin::PluginRegistry::new()), + wasm_runtime: std::sync::Arc::new( + crate::plugin::WasmRuntime::new().expect("wasm runtime"), + ), + attestation_signer: None, + official_registry: std::sync::Arc::new(tokio::sync::RwLock::new( + crate::plugin::official_registry::OfficialRegistryState::default(), + )), + official_registry_config: crate::plugin::official_registry::RegistryConfig::production( + ), + proxy_config: std::sync::Arc::new(arc_swap::ArcSwap::new(std::sync::Arc::new( + crate::proxy_config::ProxyConfig::default(), + ))), + backfill_events_tx: tokio::sync::broadcast::channel(16).0, + verbose_event_logging: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), + } + } + + /// Ensure the `spaces_enabled` feature flag reads `true` for this DB + /// (idempotent upsert into `happyview_instance_settings`). + async fn enable_spaces_feature(state: &AppState) { + let sql = crate::db::adapt_sql( + "INSERT INTO happyview_instance_settings (key, value, updated_at) VALUES (?, ?, ?) \ + ON CONFLICT (key) DO UPDATE SET value = ?, updated_at = ?", + state.db_backend, + ); + let now = crate::db::now_rfc3339(); + crate::db::query(&sql) + .bind(crate::feature_flags::FeatureFlag::SPACES_ENABLED) + .bind("true") + .bind(&now) + .bind("true") + .bind(&now) + .execute(&state.db) + .await + .expect("failed to enable spaces_enabled feature flag"); + } + + /// Force the `spaces_enabled` feature flag to read `false` for this DB + /// (idempotent upsert into `happyview_instance_settings`). + /// + /// The flag row is a single global row shared by every test against the + /// same `TEST_DATABASE_URL` database, so a test that needs the flag + /// *disabled* can't just rely on the row never having been written — + /// other tests in this module call `enable_spaces_feature` and run + /// concurrently by default. Callers that need a disabled flag should + /// call this explicitly and also tag the test `#[serial(spaces_feature_flag)]` + /// alongside every test that calls `enable_spaces_feature`. + async fn disable_spaces_feature(state: &AppState) { + let sql = crate::db::adapt_sql( + "INSERT INTO happyview_instance_settings (key, value, updated_at) VALUES (?, ?, ?) \ + ON CONFLICT (key) DO UPDATE SET value = ?, updated_at = ?", + state.db_backend, + ); + let now = crate::db::now_rfc3339(); + crate::db::query(&sql) + .bind(crate::feature_flags::FeatureFlag::SPACES_ENABLED) + .bind("false") + .bind(&now) + .bind("false") + .bind(&now) + .execute(&state.db) + .await + .expect("failed to disable spaces_enabled feature flag"); + } + + /// Build a migrated Postgres-backed `AppState` seeded with one space and + /// one write-member. Returns `(state, space_uri, member_did)`. + /// + /// Uses randomised DIDs/skeys so parallel tests sharing the same + /// `TEST_DATABASE_URL` database don't collide. + async fn db_seeded_space() -> (AppState, String, String) { + let state = db_test_state().await; + enable_spaces_feature(&state).await; + + let unique = uuid::Uuid::new_v4().simple().to_string(); + let space_id = uuid::Uuid::new_v4().to_string(); + let member_did = format!("did:plc:luawriter{unique}"); + let space_did = format!("did:plc:luaowner{unique}"); + let type_nsid = "com.example.forum"; + let skey = format!("main{unique}"); + + let space = crate::spaces::types::Space { + id: space_id.clone(), + did: space_did.clone(), + authority_did: member_did.clone(), + creator_did: member_did.clone(), + type_nsid: type_nsid.to_string(), + skey: skey.clone(), + display_name: None, + description: None, + mint_policy: crate::spaces::types::MintPolicy::MemberList, + app_access: crate::spaces::types::AppAccess::default(), + managing_app_did: None, + config: crate::spaces::types::SpaceConfig::default(), + revision: None, + created_at: crate::db::now_rfc3339(), + updated_at: crate::db::now_rfc3339(), + }; + crate::spaces::db::create_space(&state.db, state.db_backend, &space) + .await + .expect("failed to seed test space"); + + let member = crate::spaces::types::SpaceMember { + id: uuid::Uuid::new_v4().to_string(), + space_id: space_id.clone(), + did: member_did.clone(), + access: crate::spaces::types::SpaceAccess::Write, + is_delegation: false, + granted_by: None, + created_at: crate::db::now_rfc3339(), + }; + crate::spaces::db::add_member(&state.db, state.db_backend, &member) + .await + .expect("failed to seed test member"); + + let space_uri = format!("at://{space_did}/space/{type_nsid}/{skey}"); + (state, space_uri, member_did) + } + + #[tokio::test] + #[serial(spaces_feature_flag)] + async fn spaces_write_lua_write_record_then_query() { + require_test_db!(); + let (state, space_uri, member_did) = db_seeded_space().await; + + let lua = mlua::Lua::new(); + let state_arc = std::sync::Arc::new(state); + crate::lua::atproto_api::register_atproto_api(&lua, state_arc.clone(), Some(&member_did)) + .unwrap(); + super::register_spaces_write_api(&lua, state_arc.clone(), Some(&member_did)).unwrap(); + + let chunk = format!( + r#" + local space = atproto.spaces.get("{space_uri}") + assert(space ~= nil, "space handle should not be nil") + local result = space:write_record{{ + collection = "com.example.item", + record = {{ text = "hello from lua" }}, + }} + assert(result.uri ~= nil, "write_record should return a uri") + assert(result.cid ~= nil, "write_record should return a cid") + + local q = atproto.spaces.query({{ + space_uri = "{space_uri}", + collection = "com.example.item", + }}) + assert(#q.records == 1, "expected exactly one record") + return q.records[1].record.text + "# + ); + let text: String = lua.load(&chunk).eval_async().await.unwrap(); + assert_eq!(text, "hello from lua"); + } + + #[tokio::test] + #[serial(spaces_feature_flag)] + async fn spaces_create_returns_handle_and_is_queryable() { + require_test_db!(); + let state = db_test_state().await; + enable_spaces_feature(&state).await; + + let unique = uuid::Uuid::new_v4().simple().to_string(); + let creator_did = format!("did:plc:luacreator{unique}"); + let skey = format!("general{unique}"); + + let lua = mlua::Lua::new(); + let state_arc = std::sync::Arc::new(state); + crate::lua::atproto_api::register_atproto_api(&lua, state_arc.clone(), Some(&creator_did)) + .unwrap(); + super::register_spaces_write_api(&lua, state_arc.clone(), Some(&creator_did)).unwrap(); + + let chunk = format!( + r#" + local space = atproto.spaces.create{{ + type = "com.example.chat", + skey = "{skey}", + }} + assert(space ~= nil, "create should return a handle") + assert(space.uri ~= nil, "handle should have a uri") + + local again = atproto.spaces.get(space.uri) + assert(again ~= nil, "created space should be queryable via get()") + assert(again.uri == space.uri, "queried uri should match created uri") + + return space.uri + "# + ); + let uri: String = lua.load(&chunk).eval_async().await.unwrap(); + assert!(uri.starts_with("at://"), "unexpected uri: {uri}"); + assert!(uri.contains("/space/"), "unexpected uri: {uri}"); + } + + #[tokio::test] + #[serial(spaces_feature_flag)] + async fn write_record_requires_record_body() { + require_test_db!(); + let (state, space_uri, member_did) = db_seeded_space().await; + + let lua = mlua::Lua::new(); + let state_arc = std::sync::Arc::new(state); + crate::lua::atproto_api::register_atproto_api(&lua, state_arc.clone(), Some(&member_did)) + .unwrap(); + super::register_spaces_write_api(&lua, state_arc.clone(), Some(&member_did)).unwrap(); + + let chunk = format!( + r#" + local space = atproto.spaces.get("{space_uri}") + assert(space ~= nil, "space handle should not be nil") + local ok, err = pcall(function() + return space:write_record{{ collection = "com.example.item" }} + end) + assert(not ok, "write_record should fail when record is missing") + return tostring(err) + "# + ); + let err: String = lua.load(&chunk).eval_async().await.unwrap(); + assert!( + err.contains("record"), + "expected a missing-record error, got: {err}" + ); + } + + #[tokio::test] + #[serial(spaces_feature_flag)] + async fn add_member_then_new_member_can_write() { + require_test_db!(); + let (state, space_uri, authority_did) = db_seeded_space().await; + let new_member_did = format!("did:plc:newmember{}", uuid::Uuid::new_v4().simple()); + + let lua = mlua::Lua::new(); + let state_arc = std::sync::Arc::new(state); + crate::lua::atproto_api::register_atproto_api( + &lua, + state_arc.clone(), + Some(&authority_did), + ) + .unwrap(); + super::register_spaces_write_api(&lua, state_arc.clone(), Some(&authority_did)).unwrap(); + + let add_chunk = format!( + r#" + local space = atproto.spaces.get("{space_uri}") + assert(space ~= nil, "space handle should not be nil") + local member = space:add_member{{ did = "{new_member_did}", access = "write" }} + assert(member.did == "{new_member_did}", "unexpected member did") + assert(member.access == "write", "unexpected member access") + "# + ); + lua.load(&add_chunk).exec_async().await.unwrap(); + + // Switch the registered caller to the newly-added member and confirm + // they can now write to the space. + super::register_spaces_write_api(&lua, state_arc.clone(), Some(&new_member_did)).unwrap(); + let write_chunk = format!( + r#" + local space = atproto.spaces.get("{space_uri}") + local result = space:write_record{{ + collection = "com.example.item", + record = {{ text = "from new member" }}, + }} + return result.uri + "# + ); + let uri: String = lua.load(&write_chunk).eval_async().await.unwrap(); + assert!( + uri.contains(&new_member_did), + "expected record uri to be authored by the new member, got: {uri}" + ); + } + + #[tokio::test] + #[serial(spaces_feature_flag)] + async fn remove_member_revokes_write_access() { + require_test_db!(); + let (state, space_uri, authority_did) = db_seeded_space().await; + let new_member_did = format!("did:plc:removeme{}", uuid::Uuid::new_v4().simple()); + + let lua = mlua::Lua::new(); + let state_arc = std::sync::Arc::new(state); + crate::lua::atproto_api::register_atproto_api( + &lua, + state_arc.clone(), + Some(&authority_did), + ) + .unwrap(); + super::register_spaces_write_api(&lua, state_arc.clone(), Some(&authority_did)).unwrap(); + + // authority adds the new member as a write-member + let add_chunk = format!( + r#" + local space = atproto.spaces.get("{space_uri}") + assert(space ~= nil, "space handle should not be nil") + local member = space:add_member{{ did = "{new_member_did}", access = "write" }} + assert(member.did == "{new_member_did}", "unexpected member did") + "# + ); + lua.load(&add_chunk).exec_async().await.unwrap(); + + // confirm the new member can write while still a member + super::register_spaces_write_api(&lua, state_arc.clone(), Some(&new_member_did)).unwrap(); + let write_chunk = format!( + r#" + local space = atproto.spaces.get("{space_uri}") + local result = space:write_record{{ + collection = "com.example.item", + record = {{ text = "before removal" }}, + }} + return result.uri + "# + ); + let uri: String = lua.load(&write_chunk).eval_async().await.unwrap(); + assert!( + uri.contains(&new_member_did), + "expected record uri to be authored by the new member, got: {uri}" + ); + + // authority removes the member + super::register_spaces_write_api(&lua, state_arc.clone(), Some(&authority_did)).unwrap(); + let remove_chunk = format!( + r#" + local space = atproto.spaces.get("{space_uri}") + local ok = space:remove_member{{ did = "{new_member_did}" }} + assert(ok == true, "remove_member should return true") + "# + ); + lua.load(&remove_chunk).exec_async().await.unwrap(); + + // re-register as the removed member and confirm write_record now raises + super::register_spaces_write_api(&lua, state_arc.clone(), Some(&new_member_did)).unwrap(); + let write_after_chunk = format!( + r#" + local space = atproto.spaces.get("{space_uri}") + local ok, err = pcall(function() + return space:write_record{{ + collection = "com.example.item", + record = {{ text = "after removal" }}, + }} + end) + assert(not ok, "write_record should fail after the member was removed") + return tostring(err) + "# + ); + let err: String = lua.load(&write_after_chunk).eval_async().await.unwrap(); + assert!( + err.to_lowercase().contains("not a member"), + "expected a not-a-member error, got: {err}" + ); + } + + /// A member who wrote a record, and is subsequently removed from the + /// space, can no longer delete that record — even though they are still + /// its author. `delete_record` requires *current* write-membership in + /// addition to authorship; a removed member fails the membership check + /// before the ownership check is ever reached. + #[tokio::test] + #[serial(spaces_feature_flag)] + async fn delete_record_rejects_removed_member() { + require_test_db!(); + let (state, space_uri, authority_did) = db_seeded_space().await; + let new_member_did = format!("did:plc:delremoveme{}", uuid::Uuid::new_v4().simple()); + + let lua = mlua::Lua::new(); + let state_arc = std::sync::Arc::new(state); + crate::lua::atproto_api::register_atproto_api( + &lua, + state_arc.clone(), + Some(&authority_did), + ) + .unwrap(); + super::register_spaces_write_api(&lua, state_arc.clone(), Some(&authority_did)).unwrap(); + + // authority adds the new member as a write-member + let add_chunk = format!( + r#" + local space = atproto.spaces.get("{space_uri}") + assert(space ~= nil, "space handle should not be nil") + local member = space:add_member{{ did = "{new_member_did}", access = "write" }} + assert(member.did == "{new_member_did}", "unexpected member did") + "# + ); + lua.load(&add_chunk).exec_async().await.unwrap(); + + // the new member writes a record they author + super::register_spaces_write_api(&lua, state_arc.clone(), Some(&new_member_did)).unwrap(); + let write_chunk = format!( + r#" + local space = atproto.spaces.get("{space_uri}") + local result = space:write_record{{ + collection = "com.example.item", + record = {{ text = "written before removal" }}, + }} + return result.uri + "# + ); + let uri: String = lua.load(&write_chunk).eval_async().await.unwrap(); + assert!( + uri.contains(&new_member_did), + "expected record uri to be authored by the new member, got: {uri}" + ); + let rkey = uri.rsplit('/').next().unwrap().to_string(); + + // authority removes the member + super::register_spaces_write_api(&lua, state_arc.clone(), Some(&authority_did)).unwrap(); + let remove_chunk = format!( + r#" + local space = atproto.spaces.get("{space_uri}") + local ok = space:remove_member{{ did = "{new_member_did}" }} + assert(ok == true, "remove_member should return true") + "# + ); + lua.load(&remove_chunk).exec_async().await.unwrap(); + + // re-register as the removed member and confirm delete_record on + // their own previously-authored record now raises, even though they + // are still its author. + super::register_spaces_write_api(&lua, state_arc.clone(), Some(&new_member_did)).unwrap(); + let delete_chunk = format!( + r#" + local space = atproto.spaces.get("{space_uri}") + local ok, err = pcall(function() + return space:delete_record{{ + collection = "com.example.item", + rkey = "{rkey}", + }} + end) + assert(not ok, "delete_record should fail after the member was removed") + return tostring(err) + "# + ); + let err: String = lua.load(&delete_chunk).eval_async().await.unwrap(); + assert!( + err.to_lowercase().contains("not a member"), + "expected a not-a-member error, got: {err}" + ); + } + + #[tokio::test] + #[serial(spaces_feature_flag)] + async fn create_space_edge_cases() { + require_test_db!(); + let state = db_test_state().await; + enable_spaces_feature(&state).await; + + let unique = uuid::Uuid::new_v4().simple().to_string(); + let creator_did = format!("did:plc:luaedge{unique}"); + let type_nsid = "com.example.edge"; + let skey = format!("dup{unique}"); + + let lua = mlua::Lua::new(); + let state_arc = std::sync::Arc::new(state); + crate::lua::atproto_api::register_atproto_api(&lua, state_arc.clone(), Some(&creator_did)) + .unwrap(); + super::register_spaces_write_api(&lua, state_arc.clone(), Some(&creator_did)).unwrap(); + + // (a) creating a space with an already-used type/skey raises Conflict + let create_chunk = format!( + r#" + local space = atproto.spaces.create{{ + type = "{type_nsid}", + skey = "{skey}", + }} + assert(space ~= nil, "first create should succeed") + "# + ); + lua.load(&create_chunk).exec_async().await.unwrap(); + + let dup_chunk = format!( + r#" + local ok, err = pcall(function() + return atproto.spaces.create{{ + type = "{type_nsid}", + skey = "{skey}", + }} + end) + assert(not ok, "creating a duplicate type/skey should fail") + return tostring(err) + "# + ); + let err: String = lua.load(&dup_chunk).eval_async().await.unwrap(); + assert!( + err.to_lowercase().contains("already exists"), + "expected an 'already exists' error, got: {err}" + ); + + // (b) an invalid mint_policy raises before ever reaching the service layer + let bad_policy_chunk = format!( + r#" + local ok, err = pcall(function() + return atproto.spaces.create{{ + type = "{type_nsid}", + skey = "{skey}-other", + mint_policy = "not-a-real-policy", + }} + end) + assert(not ok, "creating with an invalid mint_policy should fail") + return tostring(err) + "# + ); + let err: String = lua.load(&bad_policy_chunk).eval_async().await.unwrap(); + assert!( + err.to_lowercase().contains("invalid mint_policy"), + "expected an 'invalid mint_policy' error, got: {err}" + ); + } + + #[tokio::test] + #[serial(spaces_feature_flag)] + async fn add_member_raises_for_non_admin() { + require_test_db!(); + let (state, space_uri, _authority_did) = db_seeded_space().await; + let stranger_did = format!("did:plc:stranger{}", uuid::Uuid::new_v4().simple()); + + let lua = mlua::Lua::new(); + let state_arc = std::sync::Arc::new(state); + crate::lua::atproto_api::register_atproto_api(&lua, state_arc.clone(), Some(&stranger_did)) + .unwrap(); + super::register_spaces_write_api(&lua, state_arc.clone(), Some(&stranger_did)).unwrap(); + + let chunk = format!( + r#" + local space = atproto.spaces.get("{space_uri}") + local ok, err = pcall(function() + return space:add_member{{ did = "did:plc:whoever", access = "read" }} + end) + assert(not ok, "add_member should fail for a non-admin caller") + return tostring(err) + "# + ); + let err: String = lua.load(&chunk).eval_async().await.unwrap(); + assert!( + err.contains("forbidden"), + "expected a forbidden error, got: {err}" + ); + } + + /// A `read`-access member can perform read operations (`query`, + /// `members`) but is rejected by `write_record`/`delete_record`, which + /// both call `require_membership(..., write=true)`. `SpaceAccess::can_write()` + /// is `false` for `Read`/`ReadSelf`, so this exercises the read-vs-write + /// boundary rather than the plain non-member rejection already covered + /// by `add_member_raises_for_non_admin` / `remove_member_revokes_write_access`. + #[tokio::test] + #[serial(spaces_feature_flag)] + async fn read_only_member_can_read_but_not_write() { + require_test_db!(); + let (state, space_uri, authority_did) = db_seeded_space().await; + let reader_did = format!("did:plc:reader{}", uuid::Uuid::new_v4().simple()); + + let lua = mlua::Lua::new(); + let state_arc = std::sync::Arc::new(state); + crate::lua::atproto_api::register_atproto_api( + &lua, + state_arc.clone(), + Some(&authority_did), + ) + .unwrap(); + super::register_spaces_write_api(&lua, state_arc.clone(), Some(&authority_did)).unwrap(); + + // authority adds the new member with read-only access + let add_chunk = format!( + r#" + local space = atproto.spaces.get("{space_uri}") + assert(space ~= nil, "space handle should not be nil") + local member = space:add_member{{ did = "{reader_did}", access = "read" }} + assert(member.did == "{reader_did}", "unexpected member did") + assert(member.access == "read", "unexpected member access") + "# + ); + lua.load(&add_chunk).exec_async().await.unwrap(); + + // re-register as the read-only member + super::register_spaces_write_api(&lua, state_arc.clone(), Some(&reader_did)).unwrap(); + + // reads succeed for a read-only member + let read_chunk = format!( + r#" + local space = atproto.spaces.get("{space_uri}") + assert(space ~= nil, "space handle should not be nil") + local q = space:query{{}} + assert(q.records ~= nil, "read-only member should be able to query") + local members = space:members() + assert(#members == 2, "expected two members") + assert(space:access("{reader_did}") == "read", "unexpected access level") + "# + ); + lua.load(&read_chunk).exec_async().await.unwrap(); + + // write_record raises Forbidden for a read-only member + let write_chunk = format!( + r#" + local space = atproto.spaces.get("{space_uri}") + local ok, err = pcall(function() + return space:write_record{{ + collection = "com.example.item", + record = {{ text = "should not be allowed" }}, + }} + end) + assert(not ok, "write_record should fail for a read-only member") + return tostring(err) + "# + ); + let err: String = lua.load(&write_chunk).eval_async().await.unwrap(); + assert!( + err.contains("forbidden"), + "expected a forbidden error from write_record, got: {err}" + ); + + // delete_record raises Forbidden for a read-only member + // (require_membership rejects before the record-ownership check is + // ever reached, so this holds even for a non-existent rkey). + let delete_chunk = format!( + r#" + local space = atproto.spaces.get("{space_uri}") + local ok, err = pcall(function() + return space:delete_record{{ + collection = "com.example.item", + rkey = "doesnotexist", + }} + end) + assert(not ok, "delete_record should fail for a read-only member") + return tostring(err) + "# + ); + let err: String = lua.load(&delete_chunk).eval_async().await.unwrap(); + assert!( + err.contains("forbidden"), + "expected a forbidden error from delete_record, got: {err}" + ); + } + + #[tokio::test] + #[serial(spaces_feature_flag)] + async fn update_persists_changes_and_clears_with_false() { + require_test_db!(); + let (state, space_uri, authority_did) = db_seeded_space().await; + + let lua = mlua::Lua::new(); + let state_arc = std::sync::Arc::new(state); + crate::lua::atproto_api::register_atproto_api( + &lua, + state_arc.clone(), + Some(&authority_did), + ) + .unwrap(); + super::register_spaces_write_api(&lua, state_arc.clone(), Some(&authority_did)).unwrap(); + + // Set display_name, then confirm it persisted via atproto.spaces.get. + let set_chunk = format!( + r#" + local space = atproto.spaces.get("{space_uri}") + assert(space ~= nil, "space handle should not be nil") + local ok = space:update{{ display_name = "New" }} + assert(ok == true, "update should return true") + "# + ); + lua.load(&set_chunk).exec_async().await.unwrap(); + + // The LuaSpace handle doesn't expose display_name directly, so assert + // persistence via the underlying service resolver (same path + // `atproto.spaces.get` uses). + let persisted = crate::spaces::service::resolve_space(&state_arc, &space_uri) + .await + .unwrap(); + assert_eq!(persisted.display_name.as_deref(), Some("New")); + + // Clear display_name via `false`. + let clear_chunk = format!( + r#" + local space = atproto.spaces.get("{space_uri}") + local ok = space:update{{ display_name = false }} + assert(ok == true, "update should return true") + "# + ); + lua.load(&clear_chunk).exec_async().await.unwrap(); + let cleared = crate::spaces::service::resolve_space(&state_arc, &space_uri) + .await + .unwrap(); + assert_eq!(cleared.display_name, None); + + // Delete the space, then confirm atproto.spaces.get returns nil. + let delete_chunk = format!( + r#" + local space = atproto.spaces.get("{space_uri}") + local ok = space:delete() + assert(ok == true, "delete should return true") + local gone = atproto.spaces.get("{space_uri}") + return gone == nil + "# + ); + let is_gone: bool = lua.load(&delete_chunk).eval_async().await.unwrap(); + assert!(is_gone, "space should be gone after delete"); + } + + #[tokio::test] + #[serial(spaces_feature_flag)] + async fn update_and_delete_raise_for_non_admin() { + require_test_db!(); + let (state, space_uri, _authority_did) = db_seeded_space().await; + let stranger_did = format!("did:plc:stranger{}", uuid::Uuid::new_v4().simple()); + + let lua = mlua::Lua::new(); + let state_arc = std::sync::Arc::new(state); + crate::lua::atproto_api::register_atproto_api(&lua, state_arc.clone(), Some(&stranger_did)) + .unwrap(); + super::register_spaces_write_api(&lua, state_arc.clone(), Some(&stranger_did)).unwrap(); + + let update_chunk = format!( + r#" + local space = atproto.spaces.get("{space_uri}") + local ok, err = pcall(function() + return space:update{{ display_name = "hacked" }} + end) + assert(not ok, "update should fail for a non-admin caller") + return tostring(err) + "# + ); + let err: String = lua.load(&update_chunk).eval_async().await.unwrap(); + assert!( + err.contains("forbidden"), + "expected a forbidden error, got: {err}" + ); + + let delete_chunk = format!( + r#" + local space = atproto.spaces.get("{space_uri}") + local ok, err = pcall(function() + return space:delete() + end) + assert(not ok, "delete should fail for a non-admin caller") + return tostring(err) + "# + ); + let err: String = lua.load(&delete_chunk).eval_async().await.unwrap(); + assert!( + err.contains("forbidden"), + "expected a forbidden error, got: {err}" + ); + } + + /// Extends `update_persists_changes_and_clears_with_false` (which only + /// covers `display_name` set/clear) to the structured fields: `app_access` + /// (tagged enum), `config` (struct with real fields + a flattened extra + /// key), `mint_policy`, and clearing `managing_app_did` with `false`. + /// Also asserts an invalid `mint_policy` string raises on `update`, mirroring + /// the create-side assertion in `create_space_edge_cases`. + #[tokio::test] + #[serial(spaces_feature_flag)] + async fn update_persists_structured_fields() { + require_test_db!(); + let (state, space_uri, authority_did) = db_seeded_space().await; + + let lua = mlua::Lua::new(); + let state_arc = std::sync::Arc::new(state); + crate::lua::atproto_api::register_atproto_api( + &lua, + state_arc.clone(), + Some(&authority_did), + ) + .unwrap(); + super::register_spaces_write_api(&lua, state_arc.clone(), Some(&authority_did)).unwrap(); + + // First SET managing_app_did to a real value and confirm it persisted, so + // the subsequent `false`-clear is a genuine round-trip (the seeded space + // has managing_app_did = None, so asserting None alone would pass even if + // the clear path were broken). + let set_chunk = format!( + r#" + local space = atproto.spaces.get("{space_uri}") + assert(space ~= nil, "space handle should not be nil") + assert(space:update{{ managing_app_did = "did:plc:managingapp" }} == true) + "# + ); + lua.load(&set_chunk).exec_async().await.unwrap(); + let after_set = crate::spaces::service::resolve_space(&state_arc, &space_uri) + .await + .unwrap(); + assert_eq!( + after_set.managing_app_did, + Some("did:plc:managingapp".to_string()), + "expected managing_app_did to be set before the clear" + ); + + let update_chunk = format!( + r#" + local space = atproto.spaces.get("{space_uri}") + assert(space ~= nil, "space handle should not be nil") + local ok = space:update{{ + app_access = {{ type = "allowList", allowed = {{ "did:plc:x" }} }}, + config = {{ membership_public = true, records_public = true, custom_flag = "yep" }}, + mint_policy = "public", + managing_app_did = false, + }} + assert(ok == true, "update should return true") + "# + ); + lua.load(&update_chunk).exec_async().await.unwrap(); + + let persisted = crate::spaces::service::resolve_space(&state_arc, &space_uri) + .await + .unwrap(); + + match &persisted.app_access { + crate::spaces::types::AppAccess::AllowList { allowed } => { + assert_eq!(allowed, &vec!["did:plc:x".to_string()]); + } + other => panic!("expected AllowList app_access, got {other:?}"), + } + assert!( + persisted.config.membership_public, + "expected membership_public to persist as true" + ); + assert!( + persisted.config.records_public, + "expected records_public to persist as true" + ); + assert_eq!( + persisted.config.extra.get("custom_flag"), + Some(&serde_json::Value::String("yep".to_string())), + "expected the flattened extra field to persist" + ); + assert_eq!( + persisted.mint_policy, + crate::spaces::types::MintPolicy::Public + ); + assert_eq!( + persisted.managing_app_did, None, + "expected managing_app_did to be cleared by `false`" + ); + + // An invalid mint_policy raises before ever reaching the service layer. + let bad_policy_chunk = format!( + r#" + local space = atproto.spaces.get("{space_uri}") + local ok, err = pcall(function() + return space:update{{ mint_policy = "bogus" }} + end) + assert(not ok, "update with an invalid mint_policy should fail") + return tostring(err) + "# + ); + let err: String = lua.load(&bad_policy_chunk).eval_async().await.unwrap(); + assert!( + err.to_lowercase().contains("invalid mint_policy"), + "expected an 'invalid mint_policy' error, got: {err}" + ); + } + + #[tokio::test] + #[serial(spaces_feature_flag)] + async fn members_and_query_work_without_caller() { + require_test_db!(); + let (state, space_uri, authority_did) = db_seeded_space().await; + + let lua = mlua::Lua::new(); + let state_arc = std::sync::Arc::new(state); + crate::lua::atproto_api::register_atproto_api(&lua, state_arc.clone(), None).unwrap(); + super::register_spaces_write_api(&lua, state_arc.clone(), None).unwrap(); + + let chunk = format!( + r#" + local space = atproto.spaces.get("{space_uri}") + assert(space ~= nil, "space handle should not be nil") + local members = space:members() + assert(#members == 1, "expected exactly one member") + assert(members[1].did == "{authority_did}", "unexpected member did") + assert(space:is_member("{authority_did}") == true, "authority should be a member") + assert(space:access("{authority_did}") == "write", "unexpected access level") + local q = space:query{{}} + assert(q.records ~= nil, "query should return a records table") + return members[1].access + "# + ); + let access: String = lua.load(&chunk).eval_async().await.unwrap(); + assert_eq!(access, "write"); + } + + #[tokio::test] + #[serial(spaces_feature_flag)] + async fn read_methods_gate_on_feature_flag() { + require_test_db!(); + let state = db_test_state().await; + // Intentionally do NOT call enable_spaces_feature(&state). The flag + // row is a single global row shared by every test against this DB, + // so other (non-serialized-with-us) tests could have left it + // enabled from a previous run against a persistent test database; + // explicitly force it back to disabled so this test is + // deterministic regardless of history or execution order. This + // test is tagged `#[serial(spaces_feature_flag)]` along with every + // test that calls `enable_spaces_feature`, so none of them can race + // with this flip. + disable_spaces_feature(&state).await; + + let unique = uuid::Uuid::new_v4().simple().to_string(); + let space_id = uuid::Uuid::new_v4().to_string(); + let member_did = format!("did:plc:luawriter{unique}"); + let space_did = format!("did:plc:luaowner{unique}"); + let type_nsid = "com.example.forum"; + let skey = format!("main{unique}"); + + let space = crate::spaces::types::Space { + id: space_id.clone(), + did: space_did.clone(), + authority_did: member_did.clone(), + creator_did: member_did.clone(), + type_nsid: type_nsid.to_string(), + skey: skey.clone(), + display_name: None, + description: None, + mint_policy: crate::spaces::types::MintPolicy::MemberList, + app_access: crate::spaces::types::AppAccess::default(), + managing_app_did: None, + config: crate::spaces::types::SpaceConfig::default(), + revision: None, + created_at: crate::db::now_rfc3339(), + updated_at: crate::db::now_rfc3339(), + }; + crate::spaces::db::create_space(&state.db, state.db_backend, &space) + .await + .expect("failed to seed test space"); + + let space_uri = format!("at://{space_did}/space/{type_nsid}/{skey}"); + + let lua = mlua::Lua::new(); + let state_arc = std::sync::Arc::new(state); + crate::lua::atproto_api::register_atproto_api(&lua, state_arc.clone(), None).unwrap(); + super::register_spaces_write_api(&lua, state_arc.clone(), None).unwrap(); + + // Use __test_handle rather than atproto.spaces.get(), since get() + // itself gates on the feature flag and wouldn't return a handle. + let chunk = format!( + r#" + local space = atproto.spaces.__test_handle("{space_uri}") + local ok, err = pcall(function() + return space:query{{}} + end) + assert(not ok, "query should fail when spaces feature flag is disabled") + return tostring(err) + "# + ); + let err: String = lua.load(&chunk).eval_async().await.unwrap(); + assert!( + err.contains("spaces feature is not enabled"), + "expected feature-flag error, got: {err}" + ); + } + + #[tokio::test] + #[serial(spaces_feature_flag)] + async fn create_invite_then_accept_invite_lets_joiner_write() { + require_test_db!(); + let (state, space_uri, authority_did) = db_seeded_space().await; + let joiner_did = format!("did:plc:joiner{}", uuid::Uuid::new_v4().simple()); + + let lua = mlua::Lua::new(); + let state_arc = std::sync::Arc::new(state); + crate::lua::atproto_api::register_atproto_api( + &lua, + state_arc.clone(), + Some(&authority_did), + ) + .unwrap(); + super::register_spaces_write_api(&lua, state_arc.clone(), Some(&authority_did)).unwrap(); + + let invite_chunk = format!( + r#" + local space = atproto.spaces.get("{space_uri}") + assert(space ~= nil, "space handle should not be nil") + local invite = space:create_invite{{ access = "write" }} + assert(invite.token ~= nil, "create_invite should return a token") + assert(invite.access == "write", "unexpected invite access") + return invite.token + "# + ); + let token: String = lua.load(&invite_chunk).eval_async().await.unwrap(); + + // Re-register with the joiner as the caller and accept the invite. + super::register_spaces_write_api(&lua, state_arc.clone(), Some(&joiner_did)).unwrap(); + let accept_chunk = format!( + r#" + local space = atproto.spaces.accept_invite{{ token = "{token}" }} + assert(space ~= nil, "accept_invite should return a handle") + assert(space.uri == "{space_uri}", "unexpected joined space uri") + local result = space:write_record{{ + collection = "com.example.item", + record = {{ text = "from joiner" }}, + }} + return result.uri + "# + ); + let uri: String = lua.load(&accept_chunk).eval_async().await.unwrap(); + assert!( + uri.contains(&joiner_did), + "expected record uri to be authored by the joiner, got: {uri}" + ); + } + + /// `space:query{}` pagination: `list_space_records` returns a `cursor` + /// exactly when the page is full (`records.len() == limit`), so writing + /// a count that isn't an exact multiple of the page size (5 records at + /// 2/page: pages of 2, 2, 1) exercises both the "more pages" and the + /// "final short page -> nil cursor" paths. Walks the cursor until nil and + /// asserts every written record is seen exactly once, with no overlap + /// between pages. + #[tokio::test] + #[serial(spaces_feature_flag)] + async fn query_paginates_with_cursor() { + require_test_db!(); + let (state, space_uri, member_did) = db_seeded_space().await; + let collection = "com.example.paginated"; + + let lua = mlua::Lua::new(); + let state_arc = std::sync::Arc::new(state); + crate::lua::atproto_api::register_atproto_api(&lua, state_arc.clone(), Some(&member_did)) + .unwrap(); + super::register_spaces_write_api(&lua, state_arc.clone(), Some(&member_did)).unwrap(); + + let write_chunk = format!( + r#" + local space = atproto.spaces.get("{space_uri}") + local uris = {{}} + for i = 1, 5 do + local result = space:write_record{{ + collection = "{collection}", + record = {{ text = "item " .. i }}, + }} + uris[i] = result.uri + end + return uris + "# + ); + let written_table: mlua::Table = lua.load(&write_chunk).eval_async().await.unwrap(); + let written: Vec = written_table + .sequence_values::() + .collect::>() + .unwrap(); + assert_eq!(written.len(), 5, "expected 5 records to have been written"); + + let mut seen: Vec = Vec::new(); + let mut cursor: Option = None; + let mut pages = 0; + loop { + pages += 1; + assert!(pages <= 10, "pagination did not terminate within 10 pages"); + + let cursor_literal = match &cursor { + Some(c) => format!("\"{c}\""), + None => "nil".to_string(), + }; + let page_chunk = format!( + r#" + local space = atproto.spaces.get("{space_uri}") + local q = space:query{{ + collection = "{collection}", + limit = 2, + cursor = {cursor_literal}, + }} + local uris = {{}} + for i, r in ipairs(q.records) do + uris[i] = r.uri + end + return {{ uris = uris, cursor = q.cursor }} + "# + ); + let page: mlua::Table = lua.load(&page_chunk).eval_async().await.unwrap(); + let page_uris_table: mlua::Table = page.get("uris").unwrap(); + let page_uris: Vec = page_uris_table + .sequence_values::() + .collect::>() + .unwrap(); + let next_cursor: Option = page.get("cursor").unwrap(); + + assert!( + page_uris.len() <= 2, + "expected at most 2 records per page, got {}", + page_uris.len() + ); + if cursor.is_none() { + assert_eq!(page_uris.len(), 2, "first page should be full (2 records)"); + assert!(next_cursor.is_some(), "first page should return a cursor"); + } + + for uri in &page_uris { + assert!( + !seen.contains(uri), + "record {uri} was returned on more than one page" + ); + seen.push(uri.clone()); + } + + match next_cursor { + Some(c) => cursor = Some(c), + None => break, + } + } + + let mut seen_sorted = seen.clone(); + seen_sorted.sort(); + let mut expected_sorted = written.clone(); + expected_sorted.sort(); + assert_eq!( + seen_sorted, expected_sorted, + "expected every written record to be seen exactly once across pages" + ); + } +} diff --git a/src/spaces/mod.rs b/src/spaces/mod.rs index 47ca8e5..2685d82 100644 --- a/src/spaces/mod.rs +++ b/src/spaces/mod.rs @@ -10,6 +10,7 @@ pub mod notifications; pub mod oplog; pub mod routes; pub mod scope; +pub mod service; pub mod simplespace; pub mod types; diff --git a/src/spaces/routes.rs b/src/spaces/routes.rs index d96b1a4..e5fcb8a 100644 --- a/src/spaces/routes.rs +++ b/src/spaces/routes.rs @@ -7,7 +7,6 @@ use base64::Engine as _; use k256; use serde::Deserialize; use sha2::{Digest, Sha256}; -use uuid::Uuid; use crate::AppState; use crate::auth::XrpcClaims; @@ -15,8 +14,9 @@ use crate::db::{adapt_sql, now_rfc3339}; use crate::error::AppError; use crate::lua::tid::generate_tid; use crate::spaces::scope::{SpaceReadAccess, check_delegation_token_access, check_read_access}; +use crate::spaces::service; use crate::spaces::types::*; -use crate::spaces::{SpaceUri, db, members, notifications, oplog}; +use crate::spaces::{db, members, notifications, oplog}; // --------------------------------------------------------------------------- // Request / response types @@ -360,7 +360,10 @@ fn require_auth(claims: &XrpcClaims) -> Result<&crate::auth::Claims, AppError> { /// Whether a verified space credential has been revoked (e.g. its holder was /// removed from the space). Consulted after signature/exp verification so a /// leaked or stale credential can be invalidated before its TTL expires (M3). -async fn space_credential_revoked(state: &AppState, token: &str) -> Result { +pub(crate) async fn space_credential_revoked( + state: &AppState, + token: &str, +) -> Result { let token_hash = hex::encode(Sha256::digest(token.as_bytes())); db::is_space_credential_revoked(&state.db, state.db_backend, &token_hash).await } @@ -407,99 +410,6 @@ fn require_notify_caller(claims: &XrpcClaims) -> Result { )) } -async fn resolve_space(state: &AppState, space_uri: &str) -> Result { - let uri = SpaceUri::parse(space_uri)?; - db::get_space_by_address( - &state.db, - state.db_backend, - &uri.did, - &uri.type_nsid, - &uri.skey, - ) - .await? - .ok_or_else(|| AppError::NotFound("Space not found".into())) -} - -async fn require_space_admin(state: &AppState, space: &Space, did: &str) -> Result<(), AppError> { - if space.authority_did == did { - return Ok(()); - } - let sql = adapt_sql( - "SELECT is_super FROM happyview_users WHERE did = ?", - state.db_backend, - ); - let row: Option<(i32,)> = crate::db::query_as(&sql) - .bind(did) - .fetch_optional(&state.db) - .await - .map_err(|e| AppError::Internal(format!("failed to check admin status: {e}")))?; - if row.is_some_and(|(is_super,)| is_super != 0) { - return Ok(()); - } - Err(AppError::Forbidden( - "Only the space authority can perform this action".into(), - )) -} - -async fn require_membership( - state: &AppState, - space: &Space, - did: &str, - require_write: bool, - space_credential: Option<&str>, -) -> Result { - if let Some(token) = space_credential { - let space_uri = format!( - "at://{}/space/{}/{}", - space.did, space.type_nsid, space.skey - ); - match crate::spaces::credential::verify_external_credential( - token, - &state.http, - &state.config.plc_url, - ) - .await - { - Ok(claims) if claims.sub == space_uri => { - // A revoked credential is treated as invalid — fall through to - // the local membership check rather than granting access. - if space_credential_revoked(state, token).await? { - // fall through - } else if require_write { - // External credential grants read access only. - return Err(AppError::Forbidden( - "Write access is required for this action".into(), - )); - } else { - return Ok(SpaceAccess::Read); - } - } - Ok(_) => { - // Credential is valid but for a different space — fall through - } - Err(_) => { - // External verification failed — fall through to local check - } - } - } - - let access = members::is_member(&state.db, state.db_backend, &space.id, did) - .await? - .ok_or_else(|| AppError::Forbidden("You are not a member of this space".into()))?; - if require_write && !access.can_write() { - return Err(AppError::Forbidden( - "Write access is required for this action".into(), - )); - } - Ok(access) -} - -fn content_cid(record: &serde_json::Value) -> String { - let bytes = serde_json::to_vec(record).unwrap_or_default(); - let hash = Sha256::digest(&bytes); - format!("bafyrei{}", hex::encode(&hash[..20])) -} - async fn resolve_client_id_url( state: &AppState, client_key: &str, @@ -525,7 +435,7 @@ async fn get_space( xrpc_claims: XrpcClaims, Query(query): Query, ) -> Result, AppError> { - let space = resolve_space(&state, &query.space).await?; + let space = service::resolve_space(&state, &query.space).await?; // If the space's membership is not public, require auth + membership if !space.config.membership_public { @@ -599,45 +509,16 @@ async fn create_record( Json(input): Json, ) -> Result { let did = require_auth_or_credential(&state, &xrpc_claims).await?; - let space = resolve_space(&state, &input.space).await?; - require_membership( + let (uri, cid) = service::create_record( &state, - &space, &did, - true, xrpc_claims.space_credential.as_deref(), + &input.space, + &input.collection, + input.record, ) .await?; - - let rkey = generate_tid(); - let cid = content_cid(&input.record); - let record_uri = format!( - "at://{}/space/{}/{}/{}/{}/{}", - space.did, space.type_nsid, space.skey, did, input.collection, rkey - ); - - let record = SpaceRecord { - uri: record_uri.clone(), - space_id: space.id.clone(), - author_did: did, - collection: input.collection, - rkey, - record: input.record, - cid: cid.clone(), - indexed_at: now_rfc3339(), - }; - - db::insert_space_record(&state.db, state.db_backend, &record).await?; - - let rev = generate_tid(); - db::update_space_revision(&state.db, state.db_backend, &space.id, &rev).await?; - - let body = serde_json::json!({ - "uri": record_uri, - "cid": cid, - }); - - let mut response = Json(body).into_response(); + let mut response = Json(serde_json::json!({ "uri": uri, "cid": cid })).into_response(); *response.status_mut() = StatusCode::CREATED; Ok(response) } @@ -648,48 +529,18 @@ async fn put_record( Json(input): Json, ) -> Result { let did = require_auth_or_credential(&state, &xrpc_claims).await?; - let space = resolve_space(&state, &input.space).await?; - require_membership( + let (uri, cid) = service::put_record( &state, - &space, &did, - true, xrpc_claims.space_credential.as_deref(), + &input.space, + &input.collection, + &input.rkey, + input.record, + input.swap_record, ) .await?; - - let cid = content_cid(&input.record); - let record_uri = format!( - "at://{}/space/{}/{}/{}/{}/{}", - space.did, space.type_nsid, space.skey, did, input.collection, input.rkey - ); - - let record = SpaceRecord { - uri: record_uri.clone(), - space_id: space.id.clone(), - author_did: did, - collection: input.collection, - rkey: input.rkey, - record: input.record, - cid: cid.clone(), - indexed_at: now_rfc3339(), - }; - - if let Some(swap_cid) = input.swap_record { - db::upsert_space_record_with_swap(&state.db, state.db_backend, &record, &swap_cid).await?; - } else { - db::upsert_space_record(&state.db, state.db_backend, &record).await?; - } - - let rev = generate_tid(); - db::update_space_revision(&state.db, state.db_backend, &space.id, &rev).await?; - - let body = serde_json::json!({ - "uri": record_uri, - "cid": cid, - }); - - let mut response = Json(body).into_response(); + let mut response = Json(serde_json::json!({ "uri": uri, "cid": cid })).into_response(); *response.status_mut() = StatusCode::CREATED; Ok(response) } @@ -701,35 +552,15 @@ async fn delete_record( ) -> Result, AppError> { let claims = require_auth(&xrpc_claims)?; let did = claims.did().to_string(); - let space = resolve_space(&state, &input.space).await?; - - let record_uri = format!( - "at://{}/space/{}/{}/{}/{}/{}", - space.did, space.type_nsid, space.skey, did, input.collection, input.rkey - ); - - if let Some(swap_cid) = input.swap_record { - db::delete_space_record_with_swap(&state.db, state.db_backend, &record_uri, &swap_cid) - .await?; - } else { - let record = db::get_space_record(&state.db, state.db_backend, &record_uri).await?; - match record { - Some(r) if r.author_did != did => { - return Err(AppError::Forbidden( - "You can only delete your own records".into(), - )); - } - None => { - return Err(AppError::NotFound("Record not found".into())); - } - _ => {} - } - db::delete_space_record(&state.db, state.db_backend, &record_uri).await?; - } - - let rev = generate_tid(); - db::update_space_revision(&state.db, state.db_backend, &space.id, &rev).await?; - + service::delete_record( + &state, + &did, + &input.space, + &input.collection, + &input.rkey, + input.swap_record, + ) + .await?; Ok(Json(serde_json::json!({ "success": true }))) } @@ -739,8 +570,8 @@ async fn apply_writes( Json(input): Json, ) -> Result, AppError> { let did = require_auth_or_credential(&state, &xrpc_claims).await?; - let space = resolve_space(&state, &input.space).await?; - require_membership( + let space = service::resolve_space(&state, &input.space).await?; + service::require_membership( &state, &space, &did, @@ -771,7 +602,7 @@ async fn apply_writes( value, } => { let rkey = rkey.unwrap_or_else(generate_tid); - let cid = content_cid(&value); + let cid = service::content_cid(&value); let record_uri = format!( "at://{}/space/{}/{}/{}/{}/{}", space.did, space.type_nsid, space.skey, did, collection, rkey @@ -798,7 +629,7 @@ async fn apply_writes( value, swap_record, } => { - let cid = content_cid(&value); + let cid = service::content_cid(&value); let record_uri = format!( "at://{}/space/{}/{}/{}/{}/{}", space.did, space.type_nsid, space.skey, did, collection, rkey @@ -847,6 +678,19 @@ async fn apply_writes( ) .await?; } else { + // Mirrors `service::delete_record`'s non-swap ownership + // check: only the record's own author may delete it. + let existing = + db::get_space_record(&state.db, state.db_backend, &record_uri).await?; + match existing { + Some(r) if r.author_did != did => { + return Err(AppError::Forbidden( + "You can only delete your own records".into(), + )); + } + None => return Err(AppError::NotFound("Record not found".into())), + _ => {} + } db::delete_space_record(&state.db, state.db_backend, &record_uri).await?; } results.push(serde_json::json!({})); @@ -868,9 +712,9 @@ async fn get_record( Query(query): Query, ) -> Result, AppError> { let did = require_auth_or_credential(&state, &xrpc_claims).await?; - let space = resolve_space(&state, &query.space).await?; + let space = service::resolve_space(&state, &query.space).await?; let has_credential = xrpc_claims.space_credential.is_some(); - let membership = require_membership( + let membership = service::require_membership( &state, &space, &did, @@ -905,9 +749,9 @@ async fn list_records( Query(query): Query, ) -> Result, AppError> { let did = require_auth_or_credential(&state, &xrpc_claims).await?; - let space = resolve_space(&state, &query.space).await?; + let space = service::resolve_space(&state, &query.space).await?; let has_credential = xrpc_claims.space_credential.is_some(); - let membership = require_membership( + let membership = service::require_membership( &state, &space, &did, @@ -970,28 +814,15 @@ async fn create_invite( Json(input): Json, ) -> Result { let claims = require_auth(&xrpc_claims)?; - let space = resolve_space(&state, &input.space).await?; - require_space_admin(&state, &space, claims.did()).await?; - - let mut token_bytes = [0u8; 24]; - rand::Rng::fill_bytes(&mut rand::rng(), &mut token_bytes); - let token = hex::encode(token_bytes); - let token_hash = hex::encode(Sha256::digest(token.as_bytes())); - - let invite = SpaceInvite { - id: Uuid::new_v4().to_string(), - space_id: space.id, - token_hash, - created_by: claims.did().to_string(), - access: input.access.unwrap_or(SpaceAccess::Read), - max_uses: input.max_uses, - uses: 0, - expires_at: input.expires_at, - revoked: false, - created_at: now_rfc3339(), - }; - - db::create_invite(&state.db, state.db_backend, &invite).await?; + let (invite, token) = service::create_invite( + &state, + claims.did(), + &input.space, + input.access, + input.max_uses, + input.expires_at, + ) + .await?; let mut response = Json(serde_json::json!({ "inviteId": invite.id, @@ -1011,58 +842,11 @@ async fn accept_invite( Json(input): Json, ) -> Result { let claims = require_auth(&xrpc_claims)?; - let did = claims.did().to_string(); - - let token_hash = hex::encode(Sha256::digest(input.token.as_bytes())); - let invite = db::get_invite_by_token_hash(&state.db, state.db_backend, &token_hash) - .await? - .ok_or_else(|| AppError::NotFound("Invalid invite token".into()))?; - - if invite.revoked { - return Err(AppError::BadRequest("This invite has been revoked".into())); - } - - if let Some(max) = invite.max_uses - && invite.uses >= max - { - return Err(AppError::BadRequest( - "This invite has reached its maximum uses".into(), - )); - } - - if let Some(ref expires) = invite.expires_at { - let now = now_rfc3339(); - if now > *expires { - return Err(AppError::BadRequest("This invite has expired".into())); - } - } - - let existing = db::get_member(&state.db, state.db_backend, &invite.space_id, &did).await?; - if existing.is_some() { - return Err(AppError::Conflict( - "You are already a member of this space".into(), - )); - } - - let member = SpaceMember { - id: Uuid::new_v4().to_string(), - space_id: invite.space_id.clone(), - did, - access: invite.access, - is_delegation: false, - granted_by: Some(invite.created_by.clone()), - created_at: now_rfc3339(), - }; - - db::add_member(&state.db, state.db_backend, &member).await?; - db::increment_invite_uses(&state.db, state.db_backend, &invite.id).await?; - - let space = db::get_space(&state.db, state.db_backend, &invite.space_id).await?; - let space_uri = space.map(|s| format!("at://{}/space/{}/{}", s.did, s.type_nsid, s.skey)); + let (space_uri, access) = service::accept_invite(&state, claims.did(), &input.token).await?; let mut response = Json(serde_json::json!({ "uri": space_uri, - "access": member.access, + "access": access, })) .into_response(); *response.status_mut() = StatusCode::CREATED; @@ -1075,8 +859,8 @@ async fn revoke_invite( Json(input): Json, ) -> Result, AppError> { let claims = require_auth(&xrpc_claims)?; - let space = resolve_space(&state, &input.space).await?; - require_space_admin(&state, &space, claims.did()).await?; + let space = service::resolve_space(&state, &input.space).await?; + service::require_space_admin(&state, &space, claims.did()).await?; let revoked = db::revoke_invite(&state.db, state.db_backend, &input.invite_id).await?; if !revoked { @@ -1092,8 +876,8 @@ async fn list_invites( Query(query): Query, ) -> Result, AppError> { let claims = require_auth(&xrpc_claims)?; - let space = resolve_space(&state, &query.space).await?; - require_space_admin(&state, &space, claims.did()).await?; + let space = service::resolve_space(&state, &query.space).await?; + service::require_space_admin(&state, &space, claims.did()).await?; let invites = db::list_invites(&state.db, state.db_backend, &space.id).await?; @@ -1127,9 +911,9 @@ async fn get_delegation_token( ) -> Result, AppError> { let claims = require_auth(&xrpc_claims)?; let did = claims.did().to_string(); - let space = resolve_space(&state, ¶ms.space).await?; + let space = service::resolve_space(&state, ¶ms.space).await?; - let membership = require_membership(&state, &space, &did, false, None).await?; + let membership = service::require_membership(&state, &space, &did, false, None).await?; let read_access = SpaceReadAccess::from_space_access(membership); check_delegation_token_access(read_access, false)?; @@ -1182,9 +966,9 @@ async fn get_latest_commit( Query(params): Query, ) -> Result { let did = require_auth_or_credential(&state, &claims).await?; - let space = resolve_space(&state, ¶ms.space).await?; + let space = service::resolve_space(&state, ¶ms.space).await?; let has_credential = claims.space_credential.is_some(); - let membership = require_membership( + let membership = service::require_membership( &state, &space, &did, @@ -1233,9 +1017,9 @@ async fn get_repo( Query(params): Query, ) -> Result { let did = require_auth_or_credential(&state, &claims).await?; - let space = resolve_space(&state, ¶ms.space).await?; + let space = service::resolve_space(&state, ¶ms.space).await?; let has_credential = claims.space_credential.is_some(); - let membership = require_membership( + let membership = service::require_membership( &state, &space, &did, @@ -1302,9 +1086,9 @@ async fn list_repo_ops( Query(params): Query, ) -> Result { let did = require_auth_or_credential(&state, &claims).await?; - let space = resolve_space(&state, ¶ms.space).await?; + let space = service::resolve_space(&state, ¶ms.space).await?; let has_credential = claims.space_credential.is_some(); - let membership = require_membership( + let membership = service::require_membership( &state, &space, &did, @@ -1349,7 +1133,7 @@ async fn list_repos( claims: XrpcClaims, Query(params): Query, ) -> Result { - let space = resolve_space(&state, ¶ms.space).await?; + let space = service::resolve_space(&state, ¶ms.space).await?; // The repo list is the space's participant list. Its visibility follows the // `membershipPublic` config (like `get_space` / `listMembers`): public when @@ -1358,7 +1142,7 @@ async fn list_repos( // checked membership, leaking any private space's participants (M2). if !space.config.membership_public { let did = require_auth_or_credential(&state, &claims).await?; - require_membership( + service::require_membership( &state, &space, &did, @@ -1378,9 +1162,9 @@ async fn get_space_blob( Query(params): Query, ) -> Result { let did = require_auth_or_credential(&state, &claims).await?; - let space = resolve_space(&state, ¶ms.space).await?; + let space = service::resolve_space(&state, ¶ms.space).await?; let has_credential = claims.space_credential.is_some(); - let membership = require_membership( + let membership = service::require_membership( &state, &space, &did, @@ -1451,7 +1235,7 @@ async fn register_notify( Json(input): Json, ) -> Result { let did = require_auth_or_credential(&state, &claims).await?; - let space = resolve_space(&state, &input.space).await?; + let space = service::resolve_space(&state, &input.space).await?; let id = notifications::register( &state.db, @@ -1475,8 +1259,8 @@ async fn notify_write( // write notifications. Previously this ignored the caller entirely, letting // anyone spam/forge notifications to registered endpoints (M1). let did = require_notify_caller(&claims)?; - let space = resolve_space(&state, &input.space).await?; - require_space_admin(&state, &space, &did).await?; + let space = service::resolve_space(&state, &input.space).await?; + service::require_space_admin(&state, &space, &did).await?; notifications::dispatch_write_notification( &state.db, @@ -1501,8 +1285,8 @@ async fn notify_space_deleted( // Inter-service route: only the space authority (or a super admin) may fire // "space deleted" events (which may cause consumers to purge cached data). let did = require_notify_caller(&claims)?; - let space = resolve_space(&state, &input.space).await?; - require_space_admin(&state, &space, &did).await?; + let space = service::resolve_space(&state, &input.space).await?; + service::require_space_admin(&state, &space, &did).await?; notifications::dispatch_space_deleted(&state.db, state.db_backend, &state.http, &space.id) .await?; @@ -1554,12 +1338,12 @@ async fn get_space_credential( )); } - let space = resolve_space(&state, &delegation_claims.sub).await?; + let space = service::resolve_space(&state, &delegation_claims.sub).await?; // Re-verify current membership before minting. The `MemberList` mint policy // is a no-op that trusts the delegation token, so a member removed within the // token's 60s window would otherwise still be able to mint. - require_membership(&state, &space, &delegation_claims.iss, false, None).await?; + service::require_membership(&state, &space, &delegation_claims.iss, false, None).await?; let client_id = if let Some(key) = claims.client_key() { resolve_client_id_url(&state, key).await? @@ -1592,16 +1376,16 @@ mod tests { #[test] fn content_cid_deterministic() { let record = json!({"text": "hello"}); - let cid1 = content_cid(&record); - let cid2 = content_cid(&record); + let cid1 = service::content_cid(&record); + let cid2 = service::content_cid(&record); assert_eq!(cid1, cid2); assert!(cid1.starts_with("bafyrei")); } #[test] fn content_cid_changes_for_different_records() { - let a = content_cid(&json!({"text": "hello"})); - let b = content_cid(&json!({"text": "world"})); + let a = service::content_cid(&json!({"text": "hello"})); + let b = service::content_cid(&json!({"text": "world"})); assert_ne!(a, b); } diff --git a/src/spaces/service.rs b/src/spaces/service.rs new file mode 100644 index 0000000..a6bb587 --- /dev/null +++ b/src/spaces/service.rs @@ -0,0 +1,1142 @@ +//! Core record-write service functions for spaces, shared by the HTTP handlers +//! (`src/spaces/routes.rs`) and (in a later task) a Lua binding, so both call +//! the exact same membership/validation/write logic. + +use crate::AppState; +use crate::db::{adapt_sql, now_rfc3339}; +use crate::error::AppError; +use crate::lua::tid::generate_tid; +use crate::spaces::types::*; +use crate::spaces::{SpaceUri, db, members}; +use sha2::{Digest, Sha256}; + +pub(crate) async fn resolve_space(state: &AppState, space_ref: &str) -> Result { + let uri = SpaceUri::parse(space_ref)?; + db::get_space_by_address( + &state.db, + state.db_backend, + &uri.did, + &uri.type_nsid, + &uri.skey, + ) + .await? + .ok_or_else(|| AppError::NotFound("Space not found".into())) +} + +pub(crate) fn content_cid(record: &serde_json::Value) -> String { + let bytes = serde_json::to_vec(record).unwrap_or_default(); + let hash = Sha256::digest(&bytes); + format!("bafyrei{}", hex::encode(&hash[..20])) +} + +pub(crate) async fn require_space_admin( + state: &AppState, + space: &Space, + did: &str, +) -> Result<(), AppError> { + if space.authority_did == did { + return Ok(()); + } + let sql = adapt_sql( + "SELECT is_super FROM happyview_users WHERE did = ?", + state.db_backend, + ); + let row: Option<(i32,)> = crate::db::query_as(&sql) + .bind(did) + .fetch_optional(&state.db) + .await + .map_err(|e| AppError::Internal(format!("failed to check admin status: {e}")))?; + if row.is_some_and(|(is_super,)| is_super != 0) { + return Ok(()); + } + Err(AppError::Forbidden( + "Only the space authority can perform this action".into(), + )) +} + +pub(crate) async fn require_membership( + state: &AppState, + space: &Space, + did: &str, + require_write: bool, + space_credential: Option<&str>, +) -> Result { + if let Some(token) = space_credential { + let space_uri = format!( + "at://{}/space/{}/{}", + space.did, space.type_nsid, space.skey + ); + match crate::spaces::credential::verify_external_credential( + token, + &state.http, + &state.config.plc_url, + ) + .await + { + Ok(claims) if claims.sub == space_uri => { + if crate::spaces::routes::space_credential_revoked(state, token).await? { + // fall through + } else if require_write { + return Err(AppError::Forbidden( + "Write access is required for this action".into(), + )); + } else { + return Ok(SpaceAccess::Read); + } + } + Ok(_) => {} + Err(_) => {} + } + } + let access = members::is_member(&state.db, state.db_backend, &space.id, did) + .await? + .ok_or_else(|| AppError::Forbidden("You are not a member of this space".into()))?; + if require_write && !access.can_write() { + return Err(AppError::Forbidden( + "Write access is required for this action".into(), + )); + } + Ok(access) +} + +pub(crate) async fn create_record( + state: &AppState, + did: &str, + space_credential: Option<&str>, + space_ref: &str, + collection: &str, + record: serde_json::Value, +) -> Result<(String, String), AppError> { + let space = resolve_space(state, space_ref).await?; + require_membership(state, &space, did, true, space_credential).await?; + + let rkey = generate_tid(); + let cid = content_cid(&record); + let record_uri = format!( + "at://{}/space/{}/{}/{}/{}/{}", + space.did, space.type_nsid, space.skey, did, collection, rkey + ); + let rec = SpaceRecord { + uri: record_uri.clone(), + space_id: space.id.clone(), + author_did: did.to_string(), + collection: collection.to_string(), + rkey, + record, + cid: cid.clone(), + indexed_at: now_rfc3339(), + }; + db::insert_space_record(&state.db, state.db_backend, &rec).await?; + let rev = generate_tid(); + db::update_space_revision(&state.db, state.db_backend, &space.id, &rev).await?; + Ok((record_uri, cid)) +} + +#[allow(clippy::too_many_arguments)] +pub(crate) async fn put_record( + state: &AppState, + did: &str, + space_credential: Option<&str>, + space_ref: &str, + collection: &str, + rkey: &str, + record: serde_json::Value, + swap_cid: Option, +) -> Result<(String, String), AppError> { + let space = resolve_space(state, space_ref).await?; + require_membership(state, &space, did, true, space_credential).await?; + + let cid = content_cid(&record); + let record_uri = format!( + "at://{}/space/{}/{}/{}/{}/{}", + space.did, space.type_nsid, space.skey, did, collection, rkey + ); + let rec = SpaceRecord { + uri: record_uri.clone(), + space_id: space.id.clone(), + author_did: did.to_string(), + collection: collection.to_string(), + rkey: rkey.to_string(), + record, + cid: cid.clone(), + indexed_at: now_rfc3339(), + }; + if let Some(swap) = swap_cid { + db::upsert_space_record_with_swap(&state.db, state.db_backend, &rec, &swap).await?; + } else { + db::upsert_space_record(&state.db, state.db_backend, &rec).await?; + } + let rev = generate_tid(); + db::update_space_revision(&state.db, state.db_backend, &space.id, &rev).await?; + Ok((record_uri, cid)) +} + +pub(crate) async fn delete_record( + state: &AppState, + did: &str, + space_ref: &str, + collection: &str, + rkey: &str, + swap_cid: Option, +) -> Result<(), AppError> { + let space = resolve_space(state, space_ref).await?; + require_membership(state, &space, did, true, None).await?; + + let record_uri = format!( + "at://{}/space/{}/{}/{}/{}/{}", + space.did, space.type_nsid, space.skey, did, collection, rkey + ); + if let Some(swap) = swap_cid { + db::delete_space_record_with_swap(&state.db, state.db_backend, &record_uri, &swap).await?; + } else { + let record = db::get_space_record(&state.db, state.db_backend, &record_uri).await?; + match record { + Some(r) if r.author_did != did => { + return Err(AppError::Forbidden( + "You can only delete your own records".into(), + )); + } + None => return Err(AppError::NotFound("Record not found".into())), + _ => {} + } + db::delete_space_record(&state.db, state.db_backend, &record_uri).await?; + } + let rev = generate_tid(); + db::update_space_revision(&state.db, state.db_backend, &space.id, &rev).await?; + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +pub(crate) async fn create_space( + state: &AppState, + did: &str, + type_nsid: &str, + skey: &str, + display_name: Option, + description: Option, + mint_policy: Option, + app_access: Option, + managing_app_did: Option, + config: Option, +) -> Result { + if type_nsid.is_empty() || skey.is_empty() { + return Err(AppError::BadRequest("type and skey are required".into())); + } + let existing = + db::get_space_by_address(&state.db, state.db_backend, did, type_nsid, skey).await?; + if existing.is_some() { + return Err(AppError::Conflict( + "A space with this address already exists".into(), + )); + } + let mut config = config.unwrap_or_default(); + if let Some(decl) = state.lexicons.get_space_declaration(type_nsid).await + && let Some(collections) = decl.space_collections + && !collections.is_empty() + && !config.extra.contains_key("allowedCollections") + { + config.extra.insert( + "allowedCollections".to_string(), + serde_json::Value::Array( + collections + .into_iter() + .map(serde_json::Value::String) + .collect(), + ), + ); + } + let space = Space { + id: uuid::Uuid::new_v4().to_string(), + did: did.to_string(), + authority_did: did.to_string(), + creator_did: did.to_string(), + type_nsid: type_nsid.to_string(), + skey: skey.to_string(), + display_name, + description, + mint_policy: mint_policy.unwrap_or(MintPolicy::MemberList), + app_access: app_access.unwrap_or_default(), + managing_app_did, + config, + revision: None, + created_at: now_rfc3339(), + updated_at: now_rfc3339(), + }; + db::create_space(&state.db, state.db_backend, &space).await?; + if let Some(encryption_key) = &state.config.token_encryption_key + && let Err(e) = crate::verification_methods::ensure_atproto_space_method( + &state.db, + state.db_backend, + encryption_key, + ) + .await + { + tracing::warn!("failed to auto-provision #atproto_space verification method: {e}"); + } + let member = SpaceMember { + id: uuid::Uuid::new_v4().to_string(), + space_id: space.id.clone(), + did: did.to_string(), + access: SpaceAccess::Write, + is_delegation: false, + granted_by: Some(did.to_string()), + created_at: now_rfc3339(), + }; + db::add_member(&state.db, state.db_backend, &member).await?; + Ok(space) +} + +pub(crate) async fn add_member( + state: &AppState, + actor_did: &str, + space_ref: &str, + member_did: &str, + access: Option, + is_delegation: Option, +) -> Result { + let space = resolve_space(state, space_ref).await?; + require_space_admin(state, &space, actor_did).await?; + if db::get_member(&state.db, state.db_backend, &space.id, member_did) + .await? + .is_some() + { + return Err(AppError::Conflict( + "Member already exists in this space".into(), + )); + } + let member = SpaceMember { + id: uuid::Uuid::new_v4().to_string(), + space_id: space.id, + did: member_did.to_string(), + access: access.unwrap_or(SpaceAccess::Read), + is_delegation: is_delegation.unwrap_or(false), + granted_by: Some(actor_did.to_string()), + created_at: now_rfc3339(), + }; + db::add_member(&state.db, state.db_backend, &member).await?; + Ok(member) +} + +#[allow(clippy::too_many_arguments)] +pub(crate) async fn update_space( + state: &AppState, + actor_did: &str, + space_ref: &str, + display_name: Option>, + description: Option>, + mint_policy: Option, + app_access: Option, + managing_app_did: Option>, + config: Option, +) -> Result { + let mut space = resolve_space(state, space_ref).await?; + require_space_admin(state, &space, actor_did).await?; + if let Some(name) = display_name { + space.display_name = name; + } + if let Some(desc) = description { + space.description = desc; + } + if let Some(policy) = mint_policy { + space.mint_policy = policy; + } + if let Some(access) = app_access { + space.app_access = access; + } + if let Some(did) = managing_app_did { + space.managing_app_did = did; + } + if let Some(cfg) = config { + space.config = cfg; + } + db::update_space(&state.db, state.db_backend, &space).await?; + Ok(space) +} + +pub(crate) async fn delete_space( + state: &AppState, + actor_did: &str, + space_ref: &str, +) -> Result<(), AppError> { + let space = resolve_space(state, space_ref).await?; + require_space_admin(state, &space, actor_did).await?; + db::delete_space(&state.db, state.db_backend, &space.id).await?; + Ok(()) +} + +pub(crate) async fn remove_member( + state: &AppState, + actor_did: &str, + space_ref: &str, + member_did: &str, +) -> Result<(), AppError> { + let space = resolve_space(state, space_ref).await?; + require_space_admin(state, &space, actor_did).await?; + db::revoke_space_credentials_for_member(&state.db, state.db_backend, &space.id, member_did) + .await?; + let removed = db::remove_member(&state.db, state.db_backend, &space.id, member_did).await?; + if !removed { + return Err(AppError::NotFound("Member not found in this space".into())); + } + Ok(()) +} + +pub(crate) async fn create_invite( + state: &AppState, + actor_did: &str, + space_ref: &str, + access: Option, + max_uses: Option, + expires_at: Option, +) -> Result<(SpaceInvite, String), AppError> { + let space = resolve_space(state, space_ref).await?; + require_space_admin(state, &space, actor_did).await?; + let mut token_bytes = [0u8; 24]; + rand::Rng::fill_bytes(&mut rand::rng(), &mut token_bytes); + let token = hex::encode(token_bytes); + let token_hash = hex::encode(Sha256::digest(token.as_bytes())); + let invite = SpaceInvite { + id: uuid::Uuid::new_v4().to_string(), + space_id: space.id, + token_hash, + created_by: actor_did.to_string(), + access: access.unwrap_or(SpaceAccess::Read), + max_uses, + uses: 0, + expires_at, + revoked: false, + created_at: now_rfc3339(), + }; + db::create_invite(&state.db, state.db_backend, &invite).await?; + Ok((invite, token)) +} + +pub(crate) async fn accept_invite( + state: &AppState, + did: &str, + token: &str, +) -> Result<(String, SpaceAccess), AppError> { + let token_hash = hex::encode(Sha256::digest(token.as_bytes())); + let invite = db::get_invite_by_token_hash(&state.db, state.db_backend, &token_hash) + .await? + .ok_or_else(|| AppError::NotFound("Invalid invite token".into()))?; + if invite.revoked { + return Err(AppError::BadRequest("This invite has been revoked".into())); + } + if let Some(max) = invite.max_uses + && invite.uses >= max + { + return Err(AppError::BadRequest( + "This invite has reached its maximum uses".into(), + )); + } + if let Some(ref expires) = invite.expires_at + && now_rfc3339() > *expires + { + return Err(AppError::BadRequest("This invite has expired".into())); + } + if db::get_member(&state.db, state.db_backend, &invite.space_id, did) + .await? + .is_some() + { + return Err(AppError::Conflict( + "You are already a member of this space".into(), + )); + } + let member = SpaceMember { + id: uuid::Uuid::new_v4().to_string(), + space_id: invite.space_id.clone(), + did: did.to_string(), + access: invite.access, + is_delegation: false, + granted_by: Some(invite.created_by.clone()), + created_at: now_rfc3339(), + }; + db::add_member(&state.db, state.db_backend, &member).await?; + db::increment_invite_uses(&state.db, state.db_backend, &invite.id).await?; + let space = db::get_space(&state.db, state.db_backend, &invite.space_id).await?; + let space_uri = space + .map(|s| format!("at://{}/space/{}/{}", s.did, s.type_nsid, s.skey)) + .ok_or_else(|| AppError::Internal("space vanished after member insert".into()))?; + Ok((space_uri, member.access)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Config; + use crate::db::DatabaseBackend; + use crate::lexicon::LexiconRegistry; + use tokio::sync::watch; + + /// These are integration tests requiring Postgres, mirroring the skip + /// idiom used by `tests/common`'s `require_db!` macro: when + /// `TEST_DATABASE_URL` isn't set, the test returns early instead of + /// failing, so `cargo test --lib` stays DB-free. Run for real via: + /// `TEST_DATABASE_URL=postgres://... cargo test -p happyview spaces::service` + macro_rules! require_test_db { + () => { + if std::env::var("TEST_DATABASE_URL").is_err() { + eprintln!("skipped (TEST_DATABASE_URL not set)"); + return; + } + }; + } + + /// Build an `AppState` backed by a migrated, empty Postgres database + /// (`TEST_DATABASE_URL`). Callers must have already checked + /// `require_test_db!()`. + async fn service_empty_db() -> AppState { + let url = std::env::var("TEST_DATABASE_URL") + .expect("TEST_DATABASE_URL must be set for spaces::service integration tests"); + let backend = DatabaseBackend::from_url(&url); + let pool = crate::db::connect(&url, backend).await; + + let config = Config { + host: "127.0.0.1".into(), + port: 0, + database_url: String::new(), + database_backend: backend, + public_url: String::new(), + session_secret: "test-secret".into(), + jetstream_url: String::new(), + relay_url: String::new(), + plc_url: String::new(), + static_dir: String::new(), + base_path: None, + event_log_retention_days: 30, + app_name: None, + logo_uri: None, + tos_uri: None, + policy_uri: None, + token_encryption_key: None, + default_rate_limit_capacity: 100, + default_rate_limit_refill_rate: 2.0, + }; + let (collections_tx, _) = watch::channel(vec![]); + let (labeler_subscriptions_tx, _) = watch::channel(()); + + let atrium_http = std::sync::Arc::new(atrium_oauth::DefaultHttpClient::default()); + let did_resolver = atrium_identity::did::CommonDidResolver::new( + atrium_identity::did::CommonDidResolverConfig { + plc_directory_url: "https://plc.directory".into(), + http_client: std::sync::Arc::clone(&atrium_http), + }, + ); + let handle_resolver = atrium_identity::handle::AtprotoHandleResolver::new( + atrium_identity::handle::AtprotoHandleResolverConfig { + dns_txt_resolver: crate::dns::NativeDnsResolver::new(), + http_client: atrium_http, + }, + ); + let oauth = atrium_oauth::OAuthClient::new(atrium_oauth::OAuthClientConfig { + client_metadata: atrium_oauth::AtprotoLocalhostClientMetadata { + redirect_uris: Some(vec!["http://127.0.0.1:0/auth/callback".into()]), + scopes: Some(vec![atrium_oauth::Scope::Known( + atrium_oauth::KnownScope::Atproto, + )]), + }, + keys: None, + state_store: crate::auth::oauth_store::DbStateStore::new(pool.clone(), backend), + session_store: crate::auth::oauth_store::DbSessionStore::new(pool.clone(), backend), + resolver: atrium_oauth::OAuthResolverConfig { + did_resolver, + handle_resolver, + authorization_server_metadata: Default::default(), + protected_resource_metadata: Default::default(), + }, + }) + .expect("Failed to create test OAuth client"); + + AppState { + config, + http: reqwest::Client::new(), + db: pool.clone(), + backfill_db: pool.clone(), + db_backend: backend, + domain_cache: crate::domain::DomainCache::new(), + lexicons: LexiconRegistry::new(), + collections_tx, + labeler_subscriptions_tx, + rate_limiter: crate::rate_limit::RateLimiter::new( + crate::rate_limit::RateLimitDefaults { + query_cost: 1, + procedure_cost: 1, + proxy_cost: 1, + }, + ), + oauth: std::sync::Arc::new(crate::auth::OAuthClientRegistry::new(std::sync::Arc::new( + oauth, + ))), + oauth_state_store: crate::auth::oauth_store::DbStateStore::new(pool.clone(), backend), + cookie_key: axum_extra::extract::cookie::Key::derive_from( + b"test-secret-for-tests-only-not-production", + ), + plugin_registry: std::sync::Arc::new(crate::plugin::PluginRegistry::new()), + wasm_runtime: std::sync::Arc::new( + crate::plugin::WasmRuntime::new().expect("wasm runtime"), + ), + attestation_signer: None, + official_registry: std::sync::Arc::new(tokio::sync::RwLock::new( + crate::plugin::official_registry::OfficialRegistryState::default(), + )), + official_registry_config: crate::plugin::official_registry::RegistryConfig::production( + ), + proxy_config: std::sync::Arc::new(arc_swap::ArcSwap::new(std::sync::Arc::new( + crate::proxy_config::ProxyConfig::default(), + ))), + backfill_events_tx: tokio::sync::broadcast::channel(16).0, + verbose_event_logging: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), + } + } + + /// Build a migrated Postgres-backed `AppState` seeded with one space and + /// one write-member (also the space's `authority_did`, for later tasks). + /// Returns `(state, space_uri, member_did)`. + /// + /// Uses randomised DIDs/skeys so parallel tests sharing the same + /// `TEST_DATABASE_URL` database don't collide (no truncation is needed). + async fn service_test_db() -> (AppState, String, String) { + let state = service_empty_db().await; + + let unique = uuid::Uuid::new_v4().simple().to_string(); + let space_id = uuid::Uuid::new_v4().to_string(); + let member_did = format!("did:plc:writer{unique}"); + let space_did = format!("did:plc:owner{unique}"); + let type_nsid = "com.example.forum"; + let skey = format!("main{unique}"); + + let space = Space { + id: space_id.clone(), + did: space_did.clone(), + authority_did: member_did.clone(), + creator_did: member_did.clone(), + type_nsid: type_nsid.to_string(), + skey: skey.clone(), + display_name: None, + description: None, + mint_policy: MintPolicy::MemberList, + app_access: AppAccess::default(), + managing_app_did: None, + config: SpaceConfig::default(), + revision: None, + created_at: now_rfc3339(), + updated_at: now_rfc3339(), + }; + db::create_space(&state.db, state.db_backend, &space) + .await + .expect("failed to seed test space"); + + let member = SpaceMember { + id: uuid::Uuid::new_v4().to_string(), + space_id: space_id.clone(), + did: member_did.clone(), + access: SpaceAccess::Write, + is_delegation: false, + granted_by: None, + created_at: now_rfc3339(), + }; + db::add_member(&state.db, state.db_backend, &member) + .await + .expect("failed to seed test member"); + + let space_uri = format!("at://{space_did}/space/{type_nsid}/{skey}"); + (state, space_uri, member_did) + } + + /// `delete_record` builds the record URI from the *caller's own* DID + /// (see the `record_uri` construction in `service::delete_record`), so a + /// second write-member can never naturally collide with another + /// author's URI through the normal create/put/delete API surface — both + /// segments are always the same `did`. To exercise the ownership check + /// (`r.author_did != did` -> Forbidden) at all we have to manufacture a + /// data state where the URI's embedded DID segment (member B) diverges + /// from the row's stored `author_did` (member A), by seeding the record + /// directly via `db::insert_space_record` rather than going through + /// `create_record`/`put_record`. This still exercises the real + /// `delete_record` ownership-check code path. + #[tokio::test] + async fn delete_record_forbidden_for_non_author() { + require_test_db!(); + let (state, space_uri, member_a) = service_test_db().await; // member_a is a write-member (and authority) + let member_b = format!("did:plc:writerB{}", uuid::Uuid::new_v4().simple()); + super::add_member( + &state, + &member_a, + &space_uri, + &member_b, + Some(SpaceAccess::Write), + None, + ) + .await + .expect("authority may add member B as a write-member"); + + let space = super::resolve_space(&state, &space_uri).await.unwrap(); + let collection = "com.example.item"; + let rkey = "fixedrkey-ownership"; + // This is the exact URI `delete_record` will construct when member_b + // calls it with this collection/rkey. + let record_uri = format!( + "at://{}/space/{}/{}/{}/{}/{}", + space.did, space.type_nsid, space.skey, member_b, collection, rkey + ); + let content = serde_json::json!({ "text": "authored by A" }); + let rec = SpaceRecord { + uri: record_uri.clone(), + space_id: space.id.clone(), + author_did: member_a.clone(), // stored author is A, not B + collection: collection.to_string(), + rkey: rkey.to_string(), + record: content.clone(), + cid: super::content_cid(&content), + indexed_at: now_rfc3339(), + }; + db::insert_space_record(&state.db, state.db_backend, &rec) + .await + .expect("failed to seed mismatched-author record"); + + let err = super::delete_record(&state, &member_b, &space_uri, collection, rkey, None) + .await + .unwrap_err(); + assert!( + matches!(err, crate::error::AppError::Forbidden(_)), + "expected Forbidden, got: {err:?}" + ); + // record must still be present since the delete was rejected + assert!( + db::get_space_record(&state.db, state.db_backend, &record_uri) + .await + .unwrap() + .is_some() + ); + } + + /// A caller who is not a member of the space at all must be rejected + /// with `Forbidden` (write-membership check), not `NotFound`. Before the + /// membership check was added, `delete_record` would build the record + /// URI from the *caller's own* DID, fail to find a row at that URI (since + /// the non-member never authored anything), and return `NotFound` + /// instead — masking the fact that non-members should never be allowed + /// to reach the ownership check at all. + #[tokio::test] + async fn delete_record_rejects_non_member() { + require_test_db!(); + let (state, space_uri, _member_did) = service_test_db().await; + let stranger = format!("did:plc:stranger{}", uuid::Uuid::new_v4().simple()); + + let err = super::delete_record( + &state, + &stranger, + &space_uri, + "com.example.item", + "rk1", + None, + ) + .await + .unwrap_err(); + assert!( + matches!(err, crate::error::AppError::Forbidden(_)), + "expected Forbidden, got: {err:?}" + ); + } + + #[tokio::test] + async fn accept_invite_rejects_revoked() { + require_test_db!(); + let (state, space_uri, authority) = service_test_db().await; + let (invite, token) = + super::create_invite(&state, &authority, &space_uri, None, None, None) + .await + .expect("authority creates invite"); + let revoked = db::revoke_invite(&state.db, state.db_backend, &invite.id) + .await + .expect("revoke should succeed"); + assert!(revoked); + + let err = super::accept_invite(&state, "did:plc:joiner-revoked", &token) + .await + .unwrap_err(); + match err { + crate::error::AppError::BadRequest(msg) => { + assert!( + msg.to_lowercase().contains("revoked"), + "expected 'revoked' in message, got: {msg}" + ); + } + other => panic!("expected BadRequest, got: {other:?}"), + } + } + + #[tokio::test] + async fn accept_invite_rejects_expired() { + require_test_db!(); + let (state, space_uri, authority) = service_test_db().await; + let (_invite, token) = super::create_invite( + &state, + &authority, + &space_uri, + None, + None, + Some("2000-01-01T00:00:00+00:00".to_string()), + ) + .await + .expect("authority creates invite"); + + let err = super::accept_invite(&state, "did:plc:joiner-expired", &token) + .await + .unwrap_err(); + match err { + crate::error::AppError::BadRequest(msg) => { + assert!( + msg.to_lowercase().contains("expired"), + "expected 'expired' in message, got: {msg}" + ); + } + other => panic!("expected BadRequest, got: {other:?}"), + } + } + + #[tokio::test] + async fn accept_invite_rejects_after_max_uses_exhausted() { + require_test_db!(); + let (state, space_uri, authority) = service_test_db().await; + let (_invite, token) = + super::create_invite(&state, &authority, &space_uri, None, Some(1), None) + .await + .expect("authority creates invite"); + + super::accept_invite(&state, "did:plc:joiner-x", &token) + .await + .expect("first accept (X) should succeed"); + + let err = super::accept_invite(&state, "did:plc:joiner-y", &token) + .await + .unwrap_err(); + match err { + crate::error::AppError::BadRequest(msg) => { + assert!( + msg.to_lowercase().contains("maximum uses"), + "expected 'maximum uses' in message, got: {msg}" + ); + } + other => panic!("expected BadRequest, got: {other:?}"), + } + } + + #[tokio::test] + async fn accept_invite_rejects_already_member() { + require_test_db!(); + let (state, space_uri, authority) = service_test_db().await; + let (_invite, token) = + super::create_invite(&state, &authority, &space_uri, None, None, None) + .await + .expect("authority creates invite"); + + super::accept_invite(&state, "did:plc:joiner-twice", &token) + .await + .expect("first accept should succeed"); + + let err = super::accept_invite(&state, "did:plc:joiner-twice", &token) + .await + .unwrap_err(); + assert!( + matches!(err, crate::error::AppError::Conflict(_)), + "expected Conflict, got: {err:?}" + ); + } + + #[tokio::test] + async fn put_record_upserts_then_rejects_swap_mismatch() { + require_test_db!(); + let (state, space_uri, member_did) = service_test_db().await; + let collection = "com.example.item"; + let rkey = "fixedrkey-put"; + + let (uri1, cid1) = super::put_record( + &state, + &member_did, + None, + &space_uri, + collection, + rkey, + serde_json::json!({ "text": "v1" }), + None, + ) + .await + .expect("first put_record should create the record"); + + let (uri2, cid2) = super::put_record( + &state, + &member_did, + None, + &space_uri, + collection, + rkey, + serde_json::json!({ "text": "v2" }), + None, + ) + .await + .expect("second put_record should update the existing record"); + + assert_eq!( + uri1, uri2, + "put_record on the same rkey should be idempotent on URI" + ); + assert_ne!(cid1, cid2, "content changed, so the CID should change"); + + let stored = db::get_space_record(&state.db, state.db_backend, &uri1) + .await + .unwrap() + .expect("record should exist"); + assert_eq!(stored.record, serde_json::json!({ "text": "v2" })); + assert_eq!(stored.cid, cid2); + + // confirm only one row exists for this collection (no duplicate insert) + let space = super::resolve_space(&state, &space_uri).await.unwrap(); + let (records, _cursor) = db::list_space_records( + &state.db, + state.db_backend, + &space.id, + None, + Some(collection), + 100, + None, + false, + ) + .await + .unwrap(); + assert_eq!(records.len(), 1, "expected exactly one row after upsert"); + + // wrong swap_cid should fail with a CID-mismatch Conflict + let err = super::put_record( + &state, + &member_did, + None, + &space_uri, + collection, + rkey, + serde_json::json!({ "text": "v3" }), + Some("bafyreiwrongwrongwrongwrongwrong".to_string()), + ) + .await + .unwrap_err(); + match err { + crate::error::AppError::Conflict(msg) => { + assert!( + msg.contains("CID mismatch"), + "expected 'CID mismatch' in message, got: {msg}" + ); + } + other => panic!("expected Conflict, got: {other:?}"), + } + // content must be unchanged after the rejected swap + let stored = db::get_space_record(&state.db, state.db_backend, &uri1) + .await + .unwrap() + .expect("record should still exist"); + assert_eq!(stored.record, serde_json::json!({ "text": "v2" })); + } + + #[tokio::test] + async fn delete_record_with_swap_cid() { + require_test_db!(); + let (state, space_uri, member_did) = service_test_db().await; + let collection = "com.example.item"; + let rkey = "fixedrkey-del"; + + let (uri, cid) = super::put_record( + &state, + &member_did, + None, + &space_uri, + collection, + rkey, + serde_json::json!({ "text": "to-delete" }), + None, + ) + .await + .expect("put_record should create the record"); + + // wrong swap_cid -> Conflict (record exists, per db::delete_space_record_with_swap) + let err = super::delete_record( + &state, + &member_did, + &space_uri, + collection, + rkey, + Some("bafyreiwrongwrongwrongwrongwrong".to_string()), + ) + .await + .unwrap_err(); + match err { + crate::error::AppError::Conflict(msg) => { + assert!( + msg.contains("CID mismatch"), + "expected 'CID mismatch' in message, got: {msg}" + ); + } + other => panic!("expected Conflict, got: {other:?}"), + } + // record must still be present + assert!( + db::get_space_record(&state.db, state.db_backend, &uri) + .await + .unwrap() + .is_some() + ); + + // correct swap_cid -> deletes + super::delete_record(&state, &member_did, &space_uri, collection, rkey, Some(cid)) + .await + .expect("delete with correct swap_cid should succeed"); + assert!( + db::get_space_record(&state.db, state.db_backend, &uri) + .await + .unwrap() + .is_none() + ); + } + + #[tokio::test] + async fn create_record_inserts_and_bumps_revision() { + require_test_db!(); + let (state, space_uri, member_did) = service_test_db().await; // space with member_did as write-member + let (uri, cid) = super::create_record( + &state, + &member_did, + None, + &space_uri, + "com.example.item", + serde_json::json!({ "text": "hello" }), + ) + .await + .expect("write should succeed for a write-member"); + assert!(uri.starts_with("at://")); + assert!(cid.starts_with("bafyrei")); + // revision bumped + let space = super::resolve_space(&state, &space_uri).await.unwrap(); + assert!(space.revision.is_some()); + } + + #[tokio::test] + async fn create_record_rejects_non_member() { + require_test_db!(); + let (state, space_uri, _member) = service_test_db().await; + let err = super::create_record( + &state, + "did:plc:stranger", + None, + &space_uri, + "com.example.item", + serde_json::json!({ "text": "no" }), + ) + .await + .unwrap_err(); + assert!(matches!(err, crate::error::AppError::Forbidden(_))); + } + + #[tokio::test] + async fn create_space_inserts_and_adds_creator_as_writer() { + require_test_db!(); + let state = service_empty_db().await; // migrated DB, no spaces + let space = super::create_space( + &state, + "did:plc:creator", + "com.example.chat", + "general", + None, + None, + None, + None, + None, + None, + ) + .await + .expect("create should succeed"); + assert_eq!(space.authority_did, "did:plc:creator"); + let access = crate::spaces::members::is_member( + &state.db, + state.db_backend, + &space.id, + "did:plc:creator", + ) + .await + .unwrap(); + assert_eq!(access, Some(crate::spaces::types::SpaceAccess::Write)); + } + + #[tokio::test] + async fn delete_space_requires_admin() { + require_test_db!(); + let (state, space_uri, authority) = service_test_db().await; + let err = super::delete_space(&state, "did:plc:notadmin", &space_uri) + .await + .unwrap_err(); + assert!(matches!(err, crate::error::AppError::Forbidden(_))); + super::delete_space(&state, &authority, &space_uri) + .await + .expect("authority may delete"); + assert!(super::resolve_space(&state, &space_uri).await.is_err()); // gone + } + + #[tokio::test] + async fn add_member_requires_admin() { + require_test_db!(); + let (state, space_uri, authority) = service_test_db().await; // authority is the space authority_did + // authority can add + super::add_member( + &state, + &authority, + &space_uri, + "did:plc:newbie", + Some(crate::spaces::types::SpaceAccess::Write), + None, + ) + .await + .expect("authority may add members"); + // a non-admin cannot + let err = super::add_member( + &state, + "did:plc:randomer", + &space_uri, + "did:plc:x", + None, + None, + ) + .await + .unwrap_err(); + assert!(matches!(err, crate::error::AppError::Forbidden(_))); + } + + #[tokio::test] + async fn invite_roundtrip() { + require_test_db!(); + let (state, space_uri, authority) = service_test_db().await; + let (_invite, token) = super::create_invite( + &state, + &authority, + &space_uri, + Some(crate::spaces::types::SpaceAccess::Write), + None, + None, + ) + .await + .expect("authority creates invite"); + let (joined_uri, access) = super::accept_invite(&state, "did:plc:joiner", &token) + .await + .expect("joiner redeems invite"); + assert_eq!(joined_uri, space_uri); + assert_eq!(access, crate::spaces::types::SpaceAccess::Write); + // now a member with write access + let space = super::resolve_space(&state, &space_uri).await.unwrap(); + let acc = crate::spaces::members::is_member( + &state.db, + state.db_backend, + &space.id, + "did:plc:joiner", + ) + .await + .unwrap(); + assert_eq!(acc, Some(crate::spaces::types::SpaceAccess::Write)); + } +} diff --git a/src/spaces/simplespace.rs b/src/spaces/simplespace.rs index 17b94c4..4e4e424 100644 --- a/src/spaces/simplespace.rs +++ b/src/spaces/simplespace.rs @@ -4,12 +4,11 @@ use axum::response::{IntoResponse, Response}; use axum::routing::{get, post}; use axum::{Json, Router}; use serde::Deserialize; -use uuid::Uuid; use crate::AppState; use crate::auth::XrpcClaims; -use crate::db::now_rfc3339; use crate::error::AppError; +use crate::spaces::service; use crate::spaces::types::*; use crate::spaces::{SpaceUri, db, members}; @@ -205,98 +204,24 @@ async fn create_space( Json(input): Json, ) -> Result { let claims = require_auth(&xrpc_claims)?; - let did = claims.did().to_string(); - - if input.type_nsid.is_empty() || input.skey.is_empty() { - return Err(AppError::BadRequest("type and skey are required".into())); - } - - let existing = db::get_space_by_address( - &state.db, - state.db_backend, - &did, + let space = service::create_space( + &state, + claims.did(), &input.type_nsid, &input.skey, + input.display_name, + input.description, + input.mint_policy, + input.app_access, + input.managing_app_did, + input.config, ) .await?; - if existing.is_some() { - return Err(AppError::Conflict( - "A space with this address already exists".into(), - )); - } - - // Optionally resolve the space type declaration from the lexicon registry. - // If the type NSID maps to a stored space declaration, use its collections - // as the default `allowed_collections` in the space config. - let mut config = input.config.unwrap_or_default(); - if let Some(decl) = state.lexicons.get_space_declaration(&input.type_nsid).await - && let Some(collections) = decl.space_collections - && !collections.is_empty() - && !config.extra.contains_key("allowedCollections") - { - config.extra.insert( - "allowedCollections".to_string(), - serde_json::Value::Array( - collections - .into_iter() - .map(serde_json::Value::String) - .collect(), - ), - ); - } - - let space = Space { - id: Uuid::new_v4().to_string(), - did: did.clone(), - authority_did: did.clone(), - creator_did: did.clone(), - type_nsid: input.type_nsid, - skey: input.skey, - display_name: input.display_name, - description: input.description, - mint_policy: input.mint_policy.unwrap_or(MintPolicy::MemberList), - app_access: input.app_access.unwrap_or_default(), - managing_app_did: input.managing_app_did, - config, - revision: None, - created_at: now_rfc3339(), - updated_at: now_rfc3339(), - }; - - db::create_space(&state.db, state.db_backend, &space).await?; - - // Auto-provision #atproto_space verification method if TOKEN_ENCRYPTION_KEY is available - if let Some(encryption_key) = &state.config.token_encryption_key - && let Err(e) = crate::verification_methods::ensure_atproto_space_method( - &state.db, - state.db_backend, - encryption_key, - ) - .await - { - tracing::warn!("failed to auto-provision #atproto_space verification method: {e}"); - } - - let member = SpaceMember { - id: Uuid::new_v4().to_string(), - space_id: space.id.clone(), - did: did.clone(), - access: SpaceAccess::Write, - is_delegation: false, - granted_by: Some(did), - created_at: now_rfc3339(), - }; - db::add_member(&state.db, state.db_backend, &member).await?; - let space_uri = format!( "at://{}/space/{}/{}", space.did, space.type_nsid, space.skey ); - let body = serde_json::json!({ - "uri": space_uri, - }); - - let mut response = Json(body).into_response(); + let mut response = Json(serde_json::json!({ "uri": space_uri })).into_response(); *response.status_mut() = StatusCode::CREATED; Ok(response) } @@ -307,11 +232,7 @@ async fn delete_space( Json(input): Json, ) -> Result, AppError> { let claims = require_auth(&xrpc_claims)?; - let space = resolve_space(&state, &input.space).await?; - require_space_admin(&state, &space, claims.did()).await?; - - db::delete_space(&state.db, state.db_backend, &space.id).await?; - + service::delete_space(&state, claims.did(), &input.space).await?; Ok(Json(serde_json::json!({ "success": true }))) } @@ -321,30 +242,18 @@ async fn update_space( Json(input): Json, ) -> Result, AppError> { let claims = require_auth(&xrpc_claims)?; - let mut space = resolve_space(&state, &input.space).await?; - require_space_admin(&state, &space, claims.did()).await?; - - if let Some(name) = input.display_name { - space.display_name = name; - } - if let Some(desc) = input.description { - space.description = desc; - } - if let Some(policy) = input.mint_policy { - space.mint_policy = policy; - } - if let Some(access) = input.app_access { - space.app_access = access; - } - if let Some(did) = input.managing_app_did { - space.managing_app_did = did; - } - if let Some(config) = input.config { - space.config = config; - } - - db::update_space(&state.db, state.db_backend, &space).await?; - + let space = service::update_space( + &state, + claims.did(), + &input.space, + input.display_name, + input.description, + input.mint_policy, + input.app_access, + input.managing_app_did, + input.config, + ) + .await?; let space_uri = format!( "at://{}/space/{}/{}", space.did, space.type_nsid, space.skey @@ -380,28 +289,15 @@ async fn add_member( Json(input): Json, ) -> Result { let claims = require_auth(&xrpc_claims)?; - let space = resolve_space(&state, &input.space).await?; - require_space_admin(&state, &space, claims.did()).await?; - - let existing = db::get_member(&state.db, state.db_backend, &space.id, &input.did).await?; - if existing.is_some() { - return Err(AppError::Conflict( - "Member already exists in this space".into(), - )); - } - - let member = SpaceMember { - id: Uuid::new_v4().to_string(), - space_id: space.id, - did: input.did, - access: input.access.unwrap_or(SpaceAccess::Read), - is_delegation: input.is_delegation.unwrap_or(false), - granted_by: Some(claims.did().to_string()), - created_at: now_rfc3339(), - }; - - db::add_member(&state.db, state.db_backend, &member).await?; - + let member = service::add_member( + &state, + claims.did(), + &input.space, + &input.did, + input.access, + input.is_delegation, + ) + .await?; let mut response = Json(serde_json::json!({ "member": member })).into_response(); *response.status_mut() = StatusCode::CREATED; Ok(response) @@ -413,24 +309,7 @@ async fn remove_member( Json(input): Json, ) -> Result, AppError> { let claims = require_auth(&xrpc_claims)?; - let space = resolve_space(&state, &input.space).await?; - require_space_admin(&state, &space, claims.did()).await?; - - // Revoke any outstanding space credentials before removing the membership - // row. If revocation fails we bail out with the member still present, so a - // retry can complete both steps — the reverse order could leave a removed - // member holding valid credentials until their 2h TTL (M3). Revoking for a - // non-member is a harmless no-op, so the NotFound check still gates on the - // delete below. - db::revoke_space_credentials_for_member(&state.db, state.db_backend, &space.id, &input.did) - .await?; - - let removed = db::remove_member(&state.db, state.db_backend, &space.id, &input.did).await?; - - if !removed { - return Err(AppError::NotFound("Member not found in this space".into())); - } - + service::remove_member(&state, claims.did(), &input.space, &input.did).await?; Ok(Json(serde_json::json!({ "success": true }))) } diff --git a/tests/spaces_records.rs b/tests/spaces_records.rs new file mode 100644 index 0000000..6203949 --- /dev/null +++ b/tests/spaces_records.rs @@ -0,0 +1,1212 @@ +//! HTTP-level e2e coverage for the space record-write endpoints: +//! `com.atproto.space.createRecord` / `putRecord` / `deleteRecord`. +//! +//! Mirrors the harness established by `spaces_list_repos_auth.rs` / +//! `spaces_notify_auth.rs` / `spaces_credential_mint.rs`: a `TestApp` router, +//! `feature.spaces_enabled` flipped on via the admin API, membership seeded +//! directly through `happyview::spaces::db`, and callers authenticated with a +//! signed session cookie for an arbitrary DID (`common::auth::admin_cookie_header` +//! — despite the name it just signs a `happyview_session` cookie for whatever +//! DID you pass, satisfying `require_auth`/`require_auth_or_credential`; the +//! DID need not exist in `happyview_users`). + +mod common; + +use axum::body::Body; +use axum::http::{HeaderName, HeaderValue, Request, StatusCode}; +use happyview::db::now_rfc3339; +use happyview::spaces::db as spaces_db; +use happyview::spaces::types::*; +use http_body_util::BodyExt; +use serde_json::{Value, json}; +use serial_test::serial; +use tower::ServiceExt; +use uuid::Uuid; + +use common::app::TestApp; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn rand_did(label: &str) -> String { + format!("did:plc:{label}{}", Uuid::new_v4().simple()) +} + +fn rand_skey(label: &str) -> String { + format!("{label}{}", Uuid::new_v4().simple()) +} + +async fn enable_spaces(app: &TestApp) { + let (name, value) = app.admin_cookie(); + let req = Request::builder() + .method("PUT") + .uri("/admin/settings/feature.spaces_enabled") + .header(name, value) + .header("content-type", "application/json") + .body(Body::from(json!({ "value": "true" }).to_string())) + .unwrap(); + assert!( + app.router + .clone() + .oneshot(req) + .await + .unwrap() + .status() + .is_success(), + "failed to enable spaces" + ); +} + +fn cookie_for(app: &TestApp, did: &str) -> (HeaderName, HeaderValue) { + common::auth::admin_cookie_header(did, &app.state.cookie_key) +} + +async fn json_of(resp: axum::http::Response) -> Value { + let body = resp.into_body().collect().await.unwrap().to_bytes(); + serde_json::from_slice(&body).unwrap_or(json!(null)) +} + +/// Create a space authored by `authority` with the given `skey`. Returns the +/// space's DB id and its `at://` URI. +async fn create_space(app: &TestApp, authority: &str, skey: &str) -> (String, String) { + let now = now_rfc3339(); + let id = Uuid::new_v4().to_string(); + let type_nsid = "com.example.records"; + let space = Space { + id: id.clone(), + did: authority.to_string(), + authority_did: authority.to_string(), + creator_did: authority.to_string(), + type_nsid: type_nsid.to_string(), + skey: skey.to_string(), + display_name: None, + description: None, + mint_policy: MintPolicy::MemberList, + app_access: AppAccess::Open, + managing_app_did: None, + config: SpaceConfig::default(), + revision: None, + created_at: now.clone(), + updated_at: now, + }; + spaces_db::create_space(&app.state.db, app.state.db_backend, &space) + .await + .expect("create_space failed"); + let uri = format!("at://{authority}/space/{type_nsid}/{skey}"); + (id, uri) +} + +async fn add_member(app: &TestApp, space_id: &str, did: &str, access: SpaceAccess) { + spaces_db::add_member( + &app.state.db, + app.state.db_backend, + &SpaceMember { + id: Uuid::new_v4().to_string(), + space_id: space_id.to_string(), + did: did.to_string(), + access, + is_delegation: false, + granted_by: None, + created_at: now_rfc3339(), + }, + ) + .await + .expect("add_member failed"); +} + +fn create_record_req( + space_uri: &str, + collection: &str, + record: &Value, + cookie: Option<(HeaderName, HeaderValue)>, +) -> Request { + let mut b = Request::builder() + .method("POST") + .uri("/xrpc/com.atproto.space.createRecord") + .header("content-type", "application/json"); + if let Some((name, value)) = cookie { + b = b.header(name, value); + } + b.body(Body::from( + json!({ "space": space_uri, "collection": collection, "record": record }).to_string(), + )) + .unwrap() +} + +#[allow(clippy::too_many_arguments)] +fn put_record_req( + space_uri: &str, + collection: &str, + rkey: &str, + record: &Value, + swap_record: Option<&str>, + cookie: Option<(HeaderName, HeaderValue)>, +) -> Request { + let mut body = json!({ + "space": space_uri, + "collection": collection, + "rkey": rkey, + "record": record, + }); + if let Some(swap) = swap_record { + body["swapRecord"] = json!(swap); + } + let mut b = Request::builder() + .method("POST") + .uri("/xrpc/com.atproto.space.putRecord") + .header("content-type", "application/json"); + if let Some((name, value)) = cookie { + b = b.header(name, value); + } + b.body(Body::from(body.to_string())).unwrap() +} + +fn delete_record_req( + space_uri: &str, + collection: &str, + rkey: &str, + swap_record: Option<&str>, + cookie: Option<(HeaderName, HeaderValue)>, +) -> Request { + let mut body = json!({ + "space": space_uri, + "collection": collection, + "rkey": rkey, + }); + if let Some(swap) = swap_record { + body["swapRecord"] = json!(swap); + } + let mut b = Request::builder() + .method("POST") + .uri("/xrpc/com.atproto.space.deleteRecord") + .header("content-type", "application/json"); + if let Some((name, value)) = cookie { + b = b.header(name, value); + } + b.body(Body::from(body.to_string())).unwrap() +} + +fn get_record_req( + space_uri: &str, + collection: &str, + rkey: &str, + cookie: Option<(HeaderName, HeaderValue)>, +) -> Request { + let mut b = Request::builder().method("GET").uri(format!( + "/xrpc/com.atproto.space.getRecord?space={}&collection={}&rkey={}", + urlencoding::encode(space_uri), + urlencoding::encode(collection), + urlencoding::encode(rkey), + )); + if let Some((name, value)) = cookie { + b = b.header(name, value); + } + b.body(Body::empty()).unwrap() +} + +/// Pull the trailing rkey segment off a space record `at://` URI. +fn rkey_from_uri(uri: &str) -> &str { + uri.rsplit('/') + .next() + .expect("uri must have a final segment") +} + +// --------------------------------------------------------------------------- +// createRecord +// --------------------------------------------------------------------------- + +/// A write-member's createRecord succeeds (201, {uri, cid}) and the record is +/// then retrievable via getRecord with matching cid/value. +#[tokio::test] +#[serial] +async fn create_record_succeeds_for_write_member_and_is_retrievable() { + common::require_db!(); + let app = TestApp::new().await; + enable_spaces(&app).await; + + let authority = rand_did("authority"); + let writer = rand_did("writer"); + let skey = rand_skey("space"); + let (space_id, space_uri) = create_space(&app, &authority, &skey).await; + add_member(&app, &space_id, &writer, SpaceAccess::Write).await; + + let collection = "com.example.item"; + let record = json!({ "$type": collection, "text": "hello from writer" }); + + let resp = app + .router + .clone() + .oneshot(create_record_req( + &space_uri, + collection, + &record, + Some(cookie_for(&app, &writer)), + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::CREATED); + let body = json_of(resp).await; + let uri = body["uri"].as_str().expect("uri present").to_string(); + let cid = body["cid"].as_str().expect("cid present").to_string(); + assert!(uri.starts_with("at://")); + assert!(!cid.is_empty()); + // author segment of the returned uri must be the writer's own DID + assert!( + uri.contains(&format!("/{writer}/{collection}/")), + "uri {uri} should embed the writer's DID as author" + ); + + let rkey = rkey_from_uri(&uri).to_string(); + let get_resp = app + .router + .clone() + .oneshot(get_record_req( + &space_uri, + collection, + &rkey, + Some(cookie_for(&app, &writer)), + )) + .await + .unwrap(); + assert_eq!(get_resp.status(), StatusCode::OK); + let get_body = json_of(get_resp).await; + assert_eq!(get_body["uri"], json!(uri)); + assert_eq!(get_body["cid"], json!(cid)); + assert_eq!(get_body["value"], record); +} + +/// A caller who is not a member of the space at all cannot createRecord. +#[tokio::test] +#[serial] +async fn create_record_rejects_non_member() { + common::require_db!(); + let app = TestApp::new().await; + enable_spaces(&app).await; + + let authority = rand_did("authority"); + let outsider = rand_did("outsider"); + let skey = rand_skey("space"); + let (_space_id, space_uri) = create_space(&app, &authority, &skey).await; + // outsider is intentionally never added as a member + + let resp = app + .router + .clone() + .oneshot(create_record_req( + &space_uri, + "com.example.item", + &json!({ "text": "should not land" }), + Some(cookie_for(&app, &outsider)), + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); +} + +/// An unauthenticated createRecord request is rejected before it ever reaches +/// membership checks. +#[tokio::test] +#[serial] +async fn create_record_rejects_unauthenticated() { + common::require_db!(); + let app = TestApp::new().await; + enable_spaces(&app).await; + + let authority = rand_did("authority"); + let skey = rand_skey("space"); + let (_space_id, space_uri) = create_space(&app, &authority, &skey).await; + + let resp = app + .router + .clone() + .oneshot(create_record_req( + &space_uri, + "com.example.item", + &json!({ "text": "no auth" }), + None, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); +} + +/// The legacy `dev.happyview.space.createRecord` alias must route to the same +/// handler as the `com.atproto` form. +#[tokio::test] +#[serial] +async fn create_record_legacy_alias_works() { + common::require_db!(); + let app = TestApp::new().await; + enable_spaces(&app).await; + + let authority = rand_did("authority"); + let writer = rand_did("writer"); + let skey = rand_skey("space"); + let (space_id, space_uri) = create_space(&app, &authority, &skey).await; + add_member(&app, &space_id, &writer, SpaceAccess::Write).await; + + let req = Request::builder() + .method("POST") + .uri("/xrpc/dev.happyview.space.createRecord") + .header("content-type", "application/json") + .header(cookie_for(&app, &writer).0, cookie_for(&app, &writer).1) + .body(Body::from( + json!({ + "space": space_uri, + "collection": "com.example.item", + "record": { "text": "via legacy alias" }, + }) + .to_string(), + )) + .unwrap(); + let resp = app.router.clone().oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::CREATED); +} + +// --------------------------------------------------------------------------- +// putRecord +// --------------------------------------------------------------------------- + +/// putRecord creates on first call, updates the same URI on a second call +/// with a different rkey-addressed record, and a stale swapRecord is +/// rejected with 409. +#[tokio::test] +#[serial] +async fn put_record_creates_updates_and_rejects_stale_swap() { + common::require_db!(); + let app = TestApp::new().await; + enable_spaces(&app).await; + + let authority = rand_did("authority"); + let writer = rand_did("writer"); + let skey = rand_skey("space"); + let (space_id, space_uri) = create_space(&app, &authority, &skey).await; + add_member(&app, &space_id, &writer, SpaceAccess::Write).await; + + let collection = "com.example.doc"; + let rkey = "fixed-rkey-1"; + let record_v1 = json!({ "$type": collection, "text": "v1" }); + + // First put: creates. + let resp1 = app + .router + .clone() + .oneshot(put_record_req( + &space_uri, + collection, + rkey, + &record_v1, + None, + Some(cookie_for(&app, &writer)), + )) + .await + .unwrap(); + assert_eq!(resp1.status(), StatusCode::CREATED); + let body1 = json_of(resp1).await; + let uri = body1["uri"].as_str().unwrap().to_string(); + let cid1 = body1["cid"].as_str().unwrap().to_string(); + + // Second put, same rkey, different content: updates in place. + let record_v2 = json!({ "$type": collection, "text": "v2" }); + let resp2 = app + .router + .clone() + .oneshot(put_record_req( + &space_uri, + collection, + rkey, + &record_v2, + None, + Some(cookie_for(&app, &writer)), + )) + .await + .unwrap(); + assert_eq!(resp2.status(), StatusCode::CREATED); + let body2 = json_of(resp2).await; + assert_eq!(body2["uri"], json!(uri), "update must keep the same uri"); + let cid2 = body2["cid"].as_str().unwrap().to_string(); + assert_ne!(cid1, cid2, "content changed, cid must change too"); + + // Confirm the update actually landed. + let get_resp = app + .router + .clone() + .oneshot(get_record_req( + &space_uri, + collection, + rkey, + Some(cookie_for(&app, &writer)), + )) + .await + .unwrap(); + assert_eq!(get_resp.status(), StatusCode::OK); + let get_body = json_of(get_resp).await; + assert_eq!(get_body["value"], record_v2); + assert_eq!(get_body["cid"], json!(cid2)); + + // Third put with a stale/incorrect swapRecord: rejected. + let record_v3 = json!({ "$type": collection, "text": "v3-should-not-land" }); + let resp3 = app + .router + .clone() + .oneshot(put_record_req( + &space_uri, + collection, + rkey, + &record_v3, + Some("bafyreiwrongwrongwrongwrongwrongwrongwrong00"), + Some(cookie_for(&app, &writer)), + )) + .await + .unwrap(); + assert_eq!(resp3.status(), StatusCode::CONFLICT); + + // The record must be unchanged after the rejected swap. + let get_resp2 = app + .router + .clone() + .oneshot(get_record_req( + &space_uri, + collection, + rkey, + Some(cookie_for(&app, &writer)), + )) + .await + .unwrap(); + let get_body2 = json_of(get_resp2).await; + assert_eq!( + get_body2["value"], record_v2, + "conflicting swap must not apply" + ); + assert_eq!(get_body2["cid"], json!(cid2)); + + // A *correct* swapRecord (matching the current cid) does succeed. + let record_v4 = json!({ "$type": collection, "text": "v4" }); + let resp4 = app + .router + .clone() + .oneshot(put_record_req( + &space_uri, + collection, + rkey, + &record_v4, + Some(&cid2), + Some(cookie_for(&app, &writer)), + )) + .await + .unwrap(); + assert_eq!(resp4.status(), StatusCode::CREATED); +} + +/// A non-member cannot putRecord into a space. +#[tokio::test] +#[serial] +async fn put_record_rejects_non_member() { + common::require_db!(); + let app = TestApp::new().await; + enable_spaces(&app).await; + + let authority = rand_did("authority"); + let outsider = rand_did("outsider"); + let skey = rand_skey("space"); + let (_space_id, space_uri) = create_space(&app, &authority, &skey).await; + + let resp = app + .router + .clone() + .oneshot(put_record_req( + &space_uri, + "com.example.doc", + "rk1", + &json!({ "text": "nope" }), + None, + Some(cookie_for(&app, &outsider)), + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); +} + +/// An unauthenticated putRecord request is rejected. +#[tokio::test] +#[serial] +async fn put_record_rejects_unauthenticated() { + common::require_db!(); + let app = TestApp::new().await; + enable_spaces(&app).await; + + let authority = rand_did("authority"); + let skey = rand_skey("space"); + let (_space_id, space_uri) = create_space(&app, &authority, &skey).await; + + let resp = app + .router + .clone() + .oneshot(put_record_req( + &space_uri, + "com.example.doc", + "rk1", + &json!({ "text": "nope" }), + None, + None, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); +} + +// --------------------------------------------------------------------------- +// deleteRecord +// --------------------------------------------------------------------------- + +/// A record's own author can delete it via deleteRecord, and it is gone +/// afterwards. +#[tokio::test] +#[serial] +async fn delete_record_author_can_delete_own_record() { + common::require_db!(); + let app = TestApp::new().await; + enable_spaces(&app).await; + + let authority = rand_did("authority"); + let writer = rand_did("writer"); + let skey = rand_skey("space"); + let (space_id, space_uri) = create_space(&app, &authority, &skey).await; + add_member(&app, &space_id, &writer, SpaceAccess::Write).await; + + let collection = "com.example.item"; + let create_resp = app + .router + .clone() + .oneshot(create_record_req( + &space_uri, + collection, + &json!({ "text": "delete me" }), + Some(cookie_for(&app, &writer)), + )) + .await + .unwrap(); + assert_eq!(create_resp.status(), StatusCode::CREATED); + let uri = json_of(create_resp).await["uri"] + .as_str() + .unwrap() + .to_string(); + let rkey = rkey_from_uri(&uri).to_string(); + + let del_resp = app + .router + .clone() + .oneshot(delete_record_req( + &space_uri, + collection, + &rkey, + None, + Some(cookie_for(&app, &writer)), + )) + .await + .unwrap(); + assert_eq!(del_resp.status(), StatusCode::OK); + let del_body = json_of(del_resp).await; + assert_eq!(del_body["success"], json!(true)); + + let get_resp = app + .router + .clone() + .oneshot(get_record_req( + &space_uri, + collection, + &rkey, + Some(cookie_for(&app, &writer)), + )) + .await + .unwrap(); + assert_eq!(get_resp.status(), StatusCode::NOT_FOUND); +} + +/// Deleting a record that was never created returns 404. +#[tokio::test] +#[serial] +async fn delete_record_missing_returns_not_found() { + common::require_db!(); + let app = TestApp::new().await; + enable_spaces(&app).await; + + let authority = rand_did("authority"); + let writer = rand_did("writer"); + let skey = rand_skey("space"); + let (space_id, space_uri) = create_space(&app, &authority, &skey).await; + add_member(&app, &space_id, &writer, SpaceAccess::Write).await; + + let resp = app + .router + .clone() + .oneshot(delete_record_req( + &space_uri, + "com.example.item", + "never-existed", + None, + Some(cookie_for(&app, &writer)), + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); +} + +/// deleteRecord's author-ownership guard (`service::delete_record`: "You can +/// only delete your own records") returns 403 when it is reached. +/// +/// NOTE on how this is reached: `delete_record` builds the record URI to +/// delete from the *caller's own* DID +/// (`.../space/{type}/{skey}/{did}/{collection}/{rkey}`), so a second +/// write-member calling deleteRecord with someone else's collection/rkey +/// addresses their *own* (nonexistent) URI, not the other author's record — +/// they get 404, never 403, through the normal create/put/delete API surface. +/// This mirrors an existing unit test with the same finding +/// (`src/spaces/service.rs::delete_record_forbidden_for_non_author`). To +/// exercise the ownership-check branch at the HTTP layer at all, we +/// reproduce that unit test's setup here: seed a record directly via +/// `spaces_db::insert_space_record` whose URI's embedded DID segment is +/// member B's (so `delete_record` will resolve to this exact URI when B +/// calls it) but whose stored `author_did` is member A's — a state that +/// cannot arise through the public create/put/delete API, only via direct +/// DB seeding (or, presumably, a future data-migration/import path). +#[tokio::test] +#[serial] +async fn delete_record_forbidden_when_uri_and_stored_author_diverge() { + common::require_db!(); + let app = TestApp::new().await; + enable_spaces(&app).await; + + let authority = rand_did("authority"); + let member_a = rand_did("membera"); + let member_b = rand_did("memberb"); + let skey = rand_skey("space"); + let (space_id, space_uri) = create_space(&app, &authority, &skey).await; + add_member(&app, &space_id, &member_a, SpaceAccess::Write).await; + add_member(&app, &space_id, &member_b, SpaceAccess::Write).await; + + let collection = "com.example.item"; + let rkey = "fixed-rkey-ownership"; + // The exact URI `delete_record` will construct when member_b calls it + // with this space/collection/rkey. + let record_uri = format!("{space_uri}/{member_b}/{collection}/{rkey}"); + let content = json!({ "text": "authored by A" }); + let record = SpaceRecord { + uri: record_uri.clone(), + space_id: space_id.clone(), + author_did: member_a.clone(), // stored author is A, not B + collection: collection.to_string(), + rkey: rkey.to_string(), + record: content.clone(), + cid: "bafyreiseedforownershiptest0000000000000000".to_string(), + indexed_at: now_rfc3339(), + }; + spaces_db::insert_space_record(&app.state.db, app.state.db_backend, &record) + .await + .expect("failed to seed mismatched-author record"); + + let resp = app + .router + .clone() + .oneshot(delete_record_req( + &space_uri, + collection, + rkey, + None, + Some(cookie_for(&app, &member_b)), + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); + + // The record must still be present since the delete was rejected. + let still_there = spaces_db::get_space_record(&app.state.db, app.state.db_backend, &record_uri) + .await + .unwrap(); + assert!(still_there.is_some()); +} + +/// An unauthenticated deleteRecord request is rejected. +#[tokio::test] +#[serial] +async fn delete_record_rejects_unauthenticated() { + common::require_db!(); + let app = TestApp::new().await; + enable_spaces(&app).await; + + let authority = rand_did("authority"); + let skey = rand_skey("space"); + let (_space_id, space_uri) = create_space(&app, &authority, &skey).await; + + let resp = app + .router + .clone() + .oneshot(delete_record_req( + &space_uri, + "com.example.item", + "rk1", + None, + None, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); +} + +// --------------------------------------------------------------------------- +// applyWrites +// --------------------------------------------------------------------------- + +fn write_op_create(collection: &str, rkey: Option<&str>, value: &Value) -> Value { + let mut op = json!({ "action": "create", "collection": collection, "value": value }); + if let Some(rkey) = rkey { + op["rkey"] = json!(rkey); + } + op +} + +fn write_op_update( + collection: &str, + rkey: &str, + value: &Value, + swap_record: Option<&str>, +) -> Value { + let mut op = json!({ + "action": "update", + "collection": collection, + "rkey": rkey, + "value": value, + }); + if let Some(swap) = swap_record { + op["swapRecord"] = json!(swap); + } + op +} + +fn write_op_delete(collection: &str, rkey: &str, swap_record: Option<&str>) -> Value { + let mut op = json!({ "action": "delete", "collection": collection, "rkey": rkey }); + if let Some(swap) = swap_record { + op["swapRecord"] = json!(swap); + } + op +} + +fn apply_writes_req( + space_uri: &str, + writes: Value, + swap_commit: Option<&str>, + cookie: Option<(HeaderName, HeaderValue)>, +) -> Request { + let mut body = json!({ "space": space_uri, "writes": writes }); + if let Some(swap) = swap_commit { + body["swapCommit"] = json!(swap); + } + let mut b = Request::builder() + .method("POST") + .uri("/xrpc/com.atproto.space.applyWrites") + .header("content-type", "application/json"); + if let Some((name, value)) = cookie { + b = b.header(name, value); + } + b.body(Body::from(body.to_string())).unwrap() +} + +/// A write-member's applyWrites batch (create + update + delete in one call) +/// succeeds, and every op takes effect: the created record is retrievable +/// with matching content/cid, the updated record's content/cid changed, and +/// the deleted record is gone afterwards. Also confirms the response shape +/// (`{ "results": [...] }`, one entry per write op, in order) and that the +/// created record's uri embeds the caller's own DID as author. +#[tokio::test] +#[serial] +async fn apply_writes_batch_create_update_delete_succeeds_for_write_member() { + common::require_db!(); + let app = TestApp::new().await; + enable_spaces(&app).await; + + let authority = rand_did("authority"); + let writer = rand_did("writer"); + let skey = rand_skey("space"); + let (space_id, space_uri) = create_space(&app, &authority, &skey).await; + add_member(&app, &space_id, &writer, SpaceAccess::Write).await; + + let collection = "com.example.item"; + + // Pre-seed the records that the update/delete ops in the batch will + // target, via the existing single-write endpoints. + let update_rkey = "batch-update-target"; + let update_v1 = json!({ "$type": collection, "text": "update v1" }); + let seed_update = app + .router + .clone() + .oneshot(put_record_req( + &space_uri, + collection, + update_rkey, + &update_v1, + None, + Some(cookie_for(&app, &writer)), + )) + .await + .unwrap(); + assert_eq!(seed_update.status(), StatusCode::CREATED); + let update_uri = json_of(seed_update).await["uri"] + .as_str() + .unwrap() + .to_string(); + + let delete_rkey = "batch-delete-target"; + let delete_v1 = json!({ "$type": collection, "text": "will be deleted" }); + let seed_delete = app + .router + .clone() + .oneshot(put_record_req( + &space_uri, + collection, + delete_rkey, + &delete_v1, + None, + Some(cookie_for(&app, &writer)), + )) + .await + .unwrap(); + assert_eq!(seed_delete.status(), StatusCode::CREATED); + + // The batch: create a brand-new record, update the pre-seeded one, and + // delete the other pre-seeded one. + let create_value = json!({ "$type": collection, "text": "created in batch" }); + let update_v2 = json!({ "$type": collection, "text": "update v2" }); + let writes = json!([ + write_op_create(collection, None, &create_value), + write_op_update(collection, update_rkey, &update_v2, None), + write_op_delete(collection, delete_rkey, None), + ]); + + let resp = app + .router + .clone() + .oneshot(apply_writes_req( + &space_uri, + writes, + None, + Some(cookie_for(&app, &writer)), + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = json_of(resp).await; + let results = body["results"].as_array().expect("results array"); + assert_eq!(results.len(), 3, "one result per write op, in order"); + + // create result + let create_uri = results[0]["uri"] + .as_str() + .expect("create result has uri") + .to_string(); + let create_cid = results[0]["cid"] + .as_str() + .expect("create result has cid") + .to_string(); + assert!(create_uri.starts_with("at://")); + assert!(!create_cid.is_empty()); + assert!( + create_uri.contains(&format!("/{writer}/{collection}/")), + "created record's uri must embed the caller's own DID as author" + ); + + // update result: same uri as the pre-seeded record, new cid + assert_eq!(results[1]["uri"], json!(update_uri)); + let update_cid2 = results[1]["cid"] + .as_str() + .expect("update result has cid") + .to_string(); + assert!(!update_cid2.is_empty()); + + // delete result: empty object + assert_eq!(results[2], json!({})); + + // Verify via getRecord: created record exists with expected content. + let create_rkey = rkey_from_uri(&create_uri).to_string(); + let get_created = app + .router + .clone() + .oneshot(get_record_req( + &space_uri, + collection, + &create_rkey, + Some(cookie_for(&app, &writer)), + )) + .await + .unwrap(); + assert_eq!(get_created.status(), StatusCode::OK); + let created_body = json_of(get_created).await; + assert_eq!(created_body["value"], create_value); + assert_eq!(created_body["cid"], json!(create_cid)); + + // Verify the update landed. + let get_updated = app + .router + .clone() + .oneshot(get_record_req( + &space_uri, + collection, + update_rkey, + Some(cookie_for(&app, &writer)), + )) + .await + .unwrap(); + assert_eq!(get_updated.status(), StatusCode::OK); + let updated_body = json_of(get_updated).await; + assert_eq!(updated_body["value"], update_v2); + assert_eq!(updated_body["cid"], json!(update_cid2)); + + // Verify the delete landed. + let get_deleted = app + .router + .clone() + .oneshot(get_record_req( + &space_uri, + collection, + delete_rkey, + Some(cookie_for(&app, &writer)), + )) + .await + .unwrap(); + assert_eq!(get_deleted.status(), StatusCode::NOT_FOUND); +} + +/// A non-member's applyWrites batch is rejected by the up-front membership +/// check (403), and nothing in the batch is applied — not even the first op. +#[tokio::test] +#[serial] +async fn apply_writes_rejects_non_member_and_writes_nothing() { + common::require_db!(); + let app = TestApp::new().await; + enable_spaces(&app).await; + + let authority = rand_did("authority"); + let outsider = rand_did("outsider"); + let skey = rand_skey("space"); + let (space_id, space_uri) = create_space(&app, &authority, &skey).await; + // outsider is intentionally never added as a member + + let collection = "com.example.item"; + let writes = json!([write_op_create( + collection, + Some("should-not-exist"), + &json!({ "text": "nope" }) + )]); + + let resp = app + .router + .clone() + .oneshot(apply_writes_req( + &space_uri, + writes, + None, + Some(cookie_for(&app, &outsider)), + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); + + let (records, _) = spaces_db::list_space_records( + &app.state.db, + app.state.db_backend, + &space_id, + None, + None, + 100, + None, + false, + ) + .await + .unwrap(); + assert!( + records.is_empty(), + "membership gate must reject before any write in the batch lands" + ); +} + +/// An unauthenticated applyWrites request is rejected before it ever reaches +/// membership checks. +#[tokio::test] +#[serial] +async fn apply_writes_rejects_unauthenticated() { + common::require_db!(); + let app = TestApp::new().await; + enable_spaces(&app).await; + + let authority = rand_did("authority"); + let skey = rand_skey("space"); + let (_space_id, space_uri) = create_space(&app, &authority, &skey).await; + + let writes = json!([write_op_create( + "com.example.item", + None, + &json!({ "text": "no auth" }) + )]); + + let resp = app + .router + .clone() + .oneshot(apply_writes_req(&space_uri, writes, None, None)) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); +} + +/// applyWrites calls `require_membership(..., true, ...)` up front, so a +/// read-access member (not write) is rejected the same way a non-member is, +/// and nothing is written. +#[tokio::test] +#[serial] +async fn apply_writes_rejects_read_only_member_and_writes_nothing() { + common::require_db!(); + let app = TestApp::new().await; + enable_spaces(&app).await; + + let authority = rand_did("authority"); + let reader = rand_did("reader"); + let skey = rand_skey("space"); + let (space_id, space_uri) = create_space(&app, &authority, &skey).await; + add_member(&app, &space_id, &reader, SpaceAccess::Read).await; + + let collection = "com.example.item"; + let writes = json!([write_op_create( + collection, + Some("should-not-exist"), + &json!({ "text": "nope" }) + )]); + + let resp = app + .router + .clone() + .oneshot(apply_writes_req( + &space_uri, + writes, + None, + Some(cookie_for(&app, &reader)), + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); + + let (records, _) = spaces_db::list_space_records( + &app.state.db, + app.state.db_backend, + &space_id, + None, + None, + 100, + None, + false, + ) + .await + .unwrap(); + assert!(records.is_empty()); +} + +/// applyWrites' delete op enforces the same author-ownership guard as the +/// standalone `deleteRecord` endpoint (`service::delete_record`): only the +/// record's own author may delete it via the non-swap path. Reproduces the +/// same "divergent author" seeding pattern as +/// `delete_record_forbidden_when_uri_and_stored_author_diverge` above (a +/// state that cannot arise through the public create/put/delete/applyWrites +/// API, only via direct DB seeding) to exercise the ownership-check branch: +/// a record whose URI's embedded DID segment is member B's (so applyWrites +/// resolves to this exact URI when B calls it) but whose stored +/// `author_did` is member A's. +/// +/// Before the fix in `apply_writes`'s `WriteOp::Delete` non-swap branch, +/// this op had NO author check at all and the delete succeeded (200), +/// silently destroying another member's record. This test pins the fixed +/// behavior: 403 Forbidden, and the record survives. +#[tokio::test] +#[serial] +async fn apply_writes_delete_rejects_author_mismatch() { + common::require_db!(); + let app = TestApp::new().await; + enable_spaces(&app).await; + + let authority = rand_did("authority"); + let member_a = rand_did("membera"); + let member_b = rand_did("memberb"); + let skey = rand_skey("space"); + let (space_id, space_uri) = create_space(&app, &authority, &skey).await; + add_member(&app, &space_id, &member_a, SpaceAccess::Write).await; + add_member(&app, &space_id, &member_b, SpaceAccess::Write).await; + + let collection = "com.example.item"; + let rkey = "fixed-rkey-ownership-applywrites"; + // The exact URI applyWrites' delete op will construct when member_b + // calls it with this space/collection/rkey. + let record_uri = format!("{space_uri}/{member_b}/{collection}/{rkey}"); + let record = SpaceRecord { + uri: record_uri.clone(), + space_id: space_id.clone(), + author_did: member_a.clone(), // stored author is A, not B + collection: collection.to_string(), + rkey: rkey.to_string(), + record: json!({ "text": "authored by A" }), + cid: "bafyreiseedforapplywritesownership0000000000".to_string(), + indexed_at: now_rfc3339(), + }; + spaces_db::insert_space_record(&app.state.db, app.state.db_backend, &record) + .await + .expect("failed to seed mismatched-author record"); + + let writes = json!([write_op_delete(collection, rkey, None)]); + let resp = app + .router + .clone() + .oneshot(apply_writes_req( + &space_uri, + writes, + None, + Some(cookie_for(&app, &member_b)), + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); + + let still_there = spaces_db::get_space_record(&app.state.db, app.state.db_backend, &record_uri) + .await + .unwrap(); + assert!( + still_there.is_some(), + "a record authored by someone else must survive an applyWrites delete op" + ); +} + +/// applyWrites' delete op returns 404 (mirroring deleteRecord) when the +/// non-swap path targets a record that was never created, rather than +/// silently reporting success for a no-op delete. +#[tokio::test] +#[serial] +async fn apply_writes_delete_of_nonexistent_record_returns_not_found() { + common::require_db!(); + let app = TestApp::new().await; + enable_spaces(&app).await; + + let authority = rand_did("authority"); + let writer = rand_did("writer"); + let skey = rand_skey("space"); + let (space_id, space_uri) = create_space(&app, &authority, &skey).await; + add_member(&app, &space_id, &writer, SpaceAccess::Write).await; + + let writes = json!([write_op_delete("com.example.item", "never-existed", None)]); + let resp = app + .router + .clone() + .oneshot(apply_writes_req( + &space_uri, + writes, + None, + Some(cookie_for(&app, &writer)), + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); +} diff --git a/web/src/lib/lua-completions.ts b/web/src/lib/lua-completions.ts index b6d1b51..8d02644 100644 --- a/web/src/lib/lua-completions.ts +++ b/web/src/lib/lua-completions.ts @@ -289,6 +289,32 @@ const STATIC_COMPLETIONS: LuaCompletions = { { label: "get_labels_batch", detail: "function", description: "Get labels for multiple URIs — atproto.get_labels_batch({uri1, uri2}) → {[uri]: label[]}", insertText: "get_labels_batch(${1:uris})" }, { label: "sign", detail: "function", description: "Sign a record (requires attestation signer) — atproto.sign(record) → signature", insertText: "sign(${1:record})" }, { label: "verify_signature", detail: "function", description: "Verify a record signature — atproto.verify_signature(record, sig, repo_did) → boolean", insertText: "verify_signature(${1:record}, ${2:sig}, ${3:repo_did})" }, + { label: "spaces", detail: "table", description: "Permissioned Spaces sub-table — atproto.spaces.*" }, + ], + // HappyView Spaces API (atproto.spaces.*) + spaces: [ + { label: "is_member", detail: "function", description: "Whether did is a member of the space — atproto.spaces.is_member(space_uri, did) → boolean", insertText: "is_member(${1:space_uri}, ${2:did})" }, + { label: "get_access", detail: "function", description: "Member's access level — atproto.spaces.get_access(space_uri, did) → 'read' | 'write' | nil", insertText: "get_access(${1:space_uri}, ${2:did})" }, + { label: "list_members", detail: "function", description: "List resolved members — atproto.spaces.list_members(space_uri) → {did, access}[]", insertText: "list_members(${1:space_uri})" }, + { label: "query", detail: "function", description: "List records in a space — atproto.spaces.query({space_uri, collection?, limit?, cursor?}) → {records, cursor}", insertText: "query({\n\tspace_uri = ${1:space_uri},\n\t${2}\n})" }, + { label: "get", detail: "function", description: "Look up a space (requires caller_did to write) — atproto.spaces.get(space_uri) → Space or nil", insertText: "get(${1:space_uri})" }, + { label: "create", detail: "function", description: "Create a space (requires caller_did) — atproto.spaces.create({type, skey, ...}) → Space", insertText: "create({\n\ttype = ${1:\"com.example.type\"},\n\tskey = ${2:\"skey\"},\n\t${3}\n})" }, + { label: "accept_invite", detail: "function", description: "Redeem an invite token and join a space (requires caller_did) — atproto.spaces.accept_invite({token}) → Space", insertText: "accept_invite({ token = ${1:token} })" }, + ], + // Space handle methods (returned by atproto.spaces.get/create/accept_invite) + Space: [ + { label: "write_record", detail: "method", description: "Create a record with an auto-generated rkey — space:write_record({collection, record}) → {uri, cid}", insertText: "write_record({\n\tcollection = ${1:\"com.example.collection\"},\n\trecord = {\n\t\t${2}\n\t},\n})" }, + { label: "put_record", detail: "method", description: "Create or overwrite a record at a specific rkey — space:put_record({collection, rkey, record, swap_cid?}) → {uri, cid}", insertText: "put_record({\n\tcollection = ${1:\"com.example.collection\"},\n\trkey = ${2:rkey},\n\trecord = {\n\t\t${3}\n\t},\n})" }, + { label: "delete_record", detail: "method", description: "Delete a record (author-ownership-only) — space:delete_record({collection, rkey, swap_cid?}) → true", insertText: "delete_record({ collection = ${1:\"com.example.collection\"}, rkey = ${2:rkey} })" }, + { label: "add_member", detail: "method", description: "Add a member (space-admin only) — space:add_member({did, access?, is_delegation?}) → {did, access}", insertText: "add_member({ did = ${1:did}, access = ${2:\"write\"} })" }, + { label: "remove_member", detail: "method", description: "Remove a member (space-admin only) — space:remove_member({did}) → true", insertText: "remove_member({ did = ${1:did} })" }, + { label: "members", detail: "method", description: "List resolved members — space:members() → {did, access}[]", insertText: "members()" }, + { label: "is_member", detail: "method", description: "Whether did is a member — space:is_member(did) → boolean", insertText: "is_member(${1:did})" }, + { label: "access", detail: "method", description: "Member's access level — space:access(did) → 'read' | 'write' | nil", insertText: "access(${1:did})" }, + { label: "update", detail: "method", description: "Update space metadata (space-admin only) — space:update({display_name?, description?, ...}) → true", insertText: "update({\n\t${1}\n})" }, + { label: "delete", detail: "method", description: "Delete the space (space-admin only) — space:delete() → true", insertText: "delete()" }, + { label: "query", detail: "method", description: "List records in the space — space:query({collection?, limit?, cursor?}) → {records, cursor}", insertText: "query({\n\t${1}\n})" }, + { label: "create_invite", detail: "method", description: "Create an invite token (space-admin only) — space:create_invite({access?, max_uses?, expires_at?}) → {invite_id, token, access, max_uses, expires_at}", insertText: "create_invite({\n\t${1}\n})" }, ], }; diff --git a/web/src/lib/lua-hover.ts b/web/src/lib/lua-hover.ts index b2cc7eb..c0dd45f 100644 --- a/web/src/lib/lua-hover.ts +++ b/web/src/lib/lua-hover.ts @@ -178,4 +178,29 @@ export const HOVER_DOCS = new Map([ ["atproto.get_labels_batch", { signature: "atproto.get_labels_batch({uri1, uri2, ...})", description: "Get labels for multiple URIs — returns table keyed by URI", module: "atproto" }], ["atproto.sign", { signature: "atproto.sign(record)", description: "Sign a record (requires attestation signer, procedure scripts only) — returns signature object", module: "atproto" }], ["atproto.verify_signature", { signature: "atproto.verify_signature(record, sig, repo_did)", description: "Verify a record signature — returns boolean", module: "atproto" }], + + // ── HappyView Spaces API ──────────────────────────────────────────── + // Note: hover lookup keys on the immediate `.` pair, not the + // full dotted path — so these are keyed as `spaces.*`, matching how a + // hover on `is_member` in `atproto.spaces.is_member` is resolved. + ["atproto.spaces", { signature: "atproto.spaces", description: "Permissioned Spaces sub-table", module: "atproto" }], + ["spaces.is_member", { signature: "atproto.spaces.is_member(space_uri, did)", description: "Whether did is a member of the space — returns boolean", module: "atproto" }], + ["spaces.get_access", { signature: "atproto.spaces.get_access(space_uri, did)", description: "Member's access level — returns 'read' | 'write' | nil", module: "atproto" }], + ["spaces.list_members", { signature: "atproto.spaces.list_members(space_uri)", description: "List resolved members — returns array of {did, access}", module: "atproto" }], + ["spaces.query", { signature: "atproto.spaces.query({space_uri, collection?, limit?, cursor?})", description: "List records in a space — returns {records, cursor}", module: "atproto" }], + ["spaces.get", { signature: "atproto.spaces.get(space_uri)", description: "Look up a space — returns a Space handle or nil (requires caller_did to write)", module: "atproto" }], + ["spaces.create", { signature: "atproto.spaces.create({type, skey, display_name?, ...})", description: "Create a space (requires caller_did) — returns a Space handle", module: "atproto" }], + ["spaces.accept_invite", { signature: "atproto.spaces.accept_invite({token})", description: "Redeem an invite token and join a space (requires caller_did) — returns a Space handle", module: "atproto" }], + ["Space:write_record", { signature: "space:write_record({collection, record})", description: "Create a record with an auto-generated rkey — returns {uri, cid}" }], + ["Space:put_record", { signature: "space:put_record({collection, rkey, record, swap_cid?})", description: "Create or overwrite a record at a specific rkey — returns {uri, cid}" }], + ["Space:delete_record", { signature: "space:delete_record({collection, rkey, swap_cid?})", description: "Delete a record (author-ownership-only) — returns true" }], + ["Space:add_member", { signature: "space:add_member({did, access?, is_delegation?})", description: "Add a member (space-admin only) — returns {did, access}" }], + ["Space:remove_member", { signature: "space:remove_member({did})", description: "Remove a member (space-admin only) — returns true" }], + ["Space:members", { signature: "space:members()", description: "List resolved members — returns array of {did, access}" }], + ["Space:is_member", { signature: "space:is_member(did)", description: "Whether did is a member — returns boolean" }], + ["Space:access", { signature: "space:access(did)", description: "Member's access level — returns 'read' | 'write' | nil" }], + ["Space:update", { signature: "space:update({display_name?, description?, mint_policy?, app_access?, managing_app_did?, config?})", description: "Update space metadata (space-admin only) — returns true" }], + ["Space:delete", { signature: "space:delete()", description: "Delete the space (space-admin only) — returns true" }], + ["Space:query", { signature: "space:query({collection?, limit?, cursor?})", description: "List records in the space — returns {records, cursor}" }], + ["Space:create_invite", { signature: "space:create_invite({access?, max_uses?, expires_at?})", description: "Create an invite token (space-admin only) — returns {invite_id, token, access, max_uses, expires_at}" }], ]);