An encrypted personal cloud built on the AT Protocol.

Add directory record type and mkdir command

Introduce app.opake.cloud.directory lexicon and Directory record struct
for purely organizational file hierarchy. Directories own their children
via an ordered AT-URI array (children-on-parent model). The root directory
is a lazy-created singleton at rkey "self".

Core module provides create, delete, list, entry management, and
get-or-create-root operations. Drop unused parent field from Document.

+1001 -14
+1
CHANGELOG.md
··· 12 12 - Remove bearer token authentication fallback from AppView [#26](https://issues.opake.app/issues/26.html) 13 13 14 14 ### Added 15 + - Add directory record type and mkdir command [#98](https://issues.opake.app/issues/98.html) 15 16 - Add issue tracker link to README [#142](https://issues.opake.app/issues/142.html) 16 17 - Add crosslink-issue-renderer submodule and CI/CD pipeline [#141](https://issues.opake.app/issues/141.html) 17 18 - Migrate from chainlink to crosslink and slim project docs [#137](https://issues.opake.app/issues/137.html)
+30
crates/opake-cli/src/commands/mkdir.rs
··· 1 + use anyhow::Result; 2 + use chrono::Utc; 3 + use clap::Args; 4 + use opake_core::client::Session; 5 + use opake_core::directories; 6 + 7 + use crate::commands::Execute; 8 + use crate::session::{self, CommandContext}; 9 + 10 + #[derive(Args)] 11 + /// Create a directory 12 + pub struct MkdirCommand { 13 + /// Name for the directory 14 + name: String, 15 + } 16 + 17 + impl Execute for MkdirCommand { 18 + async fn execute(self, ctx: &CommandContext) -> Result<Option<Session>> { 19 + let mut client = session::load_client(&ctx.did)?; 20 + let now = Utc::now().to_rfc3339(); 21 + 22 + let root_uri = directories::get_or_create_root(&mut client, &ctx.did, &now).await?; 23 + let directory_uri = directories::create_directory(&mut client, &self.name, &now).await?; 24 + directories::add_entry(&mut client, &root_uri, &directory_uri, &now).await?; 25 + 26 + println!("{} → {}", self.name, directory_uri); 27 + 28 + Ok(session::refreshed_session(&client)) 29 + } 30 + }
+1
crates/opake-cli/src/commands/mod.rs
··· 5 5 pub mod login; 6 6 pub mod logout; 7 7 pub mod ls; 8 + pub mod mkdir; 8 9 pub mod resolve; 9 10 pub mod revoke; 10 11 pub mod rm;
+2
crates/opake-cli/src/main.rs
··· 39 39 Download(commands::download::DownloadCommand), 40 40 Inbox(commands::inbox::InboxCommand), 41 41 Ls(commands::ls::LsCommand), 42 + Mkdir(commands::mkdir::MkdirCommand), 42 43 Rm(commands::rm::RmCommand), 43 44 Resolve(commands::resolve::ResolveCommand), 44 45 Share(commands::share::ShareCommand), ··· 95 96 Command::Download(cmd) => run_with_context(as_flag.as_deref(), cmd).await?, 96 97 Command::Inbox(cmd) => run_with_context(as_flag.as_deref(), cmd).await?, 97 98 Command::Ls(cmd) => run_with_context(as_flag.as_deref(), cmd).await?, 99 + Command::Mkdir(cmd) => run_with_context(as_flag.as_deref(), cmd).await?, 98 100 Command::Rm(cmd) => run_with_context(as_flag.as_deref(), cmd).await?, 99 101 Command::Resolve(cmd) => run_with_context(as_flag.as_deref(), cmd).await?, 100 102 Command::Share(cmd) => run_with_context(as_flag.as_deref(), cmd).await?,
+76
crates/opake-core/src/directories/create.rs
··· 1 + use log::debug; 2 + 3 + use crate::client::{Transport, XrpcClient}; 4 + use crate::error::Error; 5 + use crate::records::Directory; 6 + 7 + use super::DIRECTORY_COLLECTION; 8 + 9 + /// Create a new directory record. Returns its AT-URI. 10 + pub async fn create_directory( 11 + client: &mut XrpcClient<impl Transport>, 12 + name: &str, 13 + created_at: &str, 14 + ) -> Result<String, Error> { 15 + let directory = Directory::new(name.to_string(), created_at.to_string()); 16 + 17 + debug!("creating directory {:?}", name); 18 + let record_ref = client 19 + .create_record(DIRECTORY_COLLECTION, &directory) 20 + .await?; 21 + 22 + Ok(record_ref.uri) 23 + } 24 + 25 + #[cfg(test)] 26 + mod tests { 27 + use super::*; 28 + use crate::client::RequestBody; 29 + use crate::records::Directory; 30 + use crate::test_utils::MockTransport; 31 + 32 + use super::super::tests::{create_record_response, mock_client, TEST_DID}; 33 + 34 + #[tokio::test] 35 + async fn happy_path() { 36 + let uri = format!("at://{TEST_DID}/app.opake.cloud.directory/tid123"); 37 + let mock = MockTransport::new(); 38 + mock.enqueue(create_record_response(&uri)); 39 + 40 + let mut client = mock_client(mock.clone()); 41 + let result = create_directory(&mut client, "Photos", "2026-03-01T00:00:00Z") 42 + .await 43 + .unwrap(); 44 + 45 + assert_eq!(result, uri); 46 + 47 + let reqs = mock.requests(); 48 + assert_eq!(reqs.len(), 1); 49 + assert!(reqs[0].url.contains("createRecord")); 50 + 51 + match &reqs[0].body { 52 + Some(RequestBody::Json(v)) => { 53 + assert_eq!(v["collection"], "app.opake.cloud.directory"); 54 + let record: Directory = serde_json::from_value(v["record"].clone()).unwrap(); 55 + assert_eq!(record.name, "Photos"); 56 + assert!(record.entries.is_empty()); 57 + } 58 + _ => panic!("expected JSON body"), 59 + } 60 + } 61 + 62 + #[tokio::test] 63 + async fn pds_error_propagates() { 64 + let mock = MockTransport::new(); 65 + mock.enqueue(crate::client::HttpResponse { 66 + status: 500, 67 + body: br#"{"error":"InternalServerError","message":"oops"}"#.to_vec(), 68 + }); 69 + 70 + let mut client = mock_client(mock); 71 + let err = create_directory(&mut client, "Broken", "2026-03-01T00:00:00Z") 72 + .await 73 + .unwrap_err(); 74 + assert!(matches!(err, Error::Xrpc { .. })); 75 + } 76 + }
+145
crates/opake-core/src/directories/delete.rs
··· 1 + use log::debug; 2 + 3 + use crate::atproto; 4 + use crate::client::{Transport, XrpcClient}; 5 + use crate::error::Error; 6 + use crate::records::{self, Directory}; 7 + 8 + use super::{DIRECTORY_COLLECTION, ROOT_DIRECTORY_RKEY}; 9 + 10 + /// Delete a directory record by AT-URI. 11 + /// 12 + /// Rejects deletion of the root directory (rkey "self") and non-empty 13 + /// directories. Validates the collection is `app.opake.cloud.directory`. 14 + pub async fn delete_directory( 15 + client: &mut XrpcClient<impl Transport>, 16 + uri: &str, 17 + ) -> Result<(), Error> { 18 + let at_uri = atproto::parse_at_uri(uri)?; 19 + 20 + if at_uri.collection != DIRECTORY_COLLECTION { 21 + return Err(Error::InvalidRecord(format!( 22 + "expected a directory URI ({}), got collection: {}", 23 + DIRECTORY_COLLECTION, at_uri.collection, 24 + ))); 25 + } 26 + 27 + if at_uri.rkey == ROOT_DIRECTORY_RKEY { 28 + return Err(Error::InvalidRecord( 29 + "cannot delete the root directory".into(), 30 + )); 31 + } 32 + 33 + debug!("fetching directory to check emptiness: {}", uri); 34 + let entry = client 35 + .get_record(&at_uri.authority, &at_uri.collection, &at_uri.rkey) 36 + .await?; 37 + 38 + let directory: Directory = serde_json::from_value(entry.value)?; 39 + records::check_version(directory.version)?; 40 + 41 + if !directory.entries.is_empty() { 42 + return Err(Error::InvalidRecord(format!( 43 + "directory is not empty ({} entries)", 44 + directory.entries.len(), 45 + ))); 46 + } 47 + 48 + debug!("deleting directory {}", uri); 49 + client 50 + .delete_record(&at_uri.collection, &at_uri.rkey) 51 + .await?; 52 + 53 + Ok(()) 54 + } 55 + 56 + #[cfg(test)] 57 + mod tests { 58 + use super::*; 59 + use crate::client::HttpResponse; 60 + use crate::test_utils::MockTransport; 61 + 62 + use super::super::tests::{ 63 + dummy_directory, dummy_directory_with_entries, get_record_response, mock_client, TEST_DID, 64 + }; 65 + 66 + #[tokio::test] 67 + async fn happy_path() { 68 + let directory = dummy_directory("Photos"); 69 + let uri = format!("at://{TEST_DID}/app.opake.cloud.directory/dir1"); 70 + let mock = MockTransport::new(); 71 + mock.enqueue(get_record_response(&uri, &directory)); 72 + mock.enqueue(HttpResponse { 73 + status: 200, 74 + body: b"{}".to_vec(), 75 + }); 76 + 77 + let mut client = mock_client(mock.clone()); 78 + delete_directory(&mut client, &uri).await.unwrap(); 79 + 80 + let reqs = mock.requests(); 81 + assert_eq!(reqs.len(), 2); 82 + assert!(reqs[0].url.contains("getRecord")); 83 + assert!(reqs[1].url.contains("deleteRecord")); 84 + } 85 + 86 + #[tokio::test] 87 + async fn rejects_root_deletion() { 88 + let mock = MockTransport::new(); 89 + let mut client = mock_client(mock); 90 + let uri = format!("at://{TEST_DID}/app.opake.cloud.directory/self"); 91 + 92 + let err = delete_directory(&mut client, &uri).await.unwrap_err(); 93 + assert!(err.to_string().contains("root directory")); 94 + } 95 + 96 + #[tokio::test] 97 + async fn rejects_non_empty_directory() { 98 + let directory = dummy_directory_with_entries( 99 + "Photos", 100 + vec!["at://did:plc:test/app.opake.cloud.document/doc1".into()], 101 + ); 102 + let uri = format!("at://{TEST_DID}/app.opake.cloud.directory/dir1"); 103 + let mock = MockTransport::new(); 104 + mock.enqueue(get_record_response(&uri, &directory)); 105 + 106 + let mut client = mock_client(mock); 107 + let err = delete_directory(&mut client, &uri).await.unwrap_err(); 108 + assert!(err.to_string().contains("not empty")); 109 + } 110 + 111 + #[tokio::test] 112 + async fn rejects_wrong_collection() { 113 + let mock = MockTransport::new(); 114 + let mut client = mock_client(mock); 115 + let uri = format!("at://{TEST_DID}/app.opake.cloud.document/abc"); 116 + 117 + let err = delete_directory(&mut client, &uri).await.unwrap_err(); 118 + assert!(err.to_string().contains("expected a directory URI")); 119 + } 120 + 121 + #[tokio::test] 122 + async fn rejects_invalid_uri() { 123 + let mock = MockTransport::new(); 124 + let mut client = mock_client(mock); 125 + 126 + let err = delete_directory(&mut client, "not-a-uri") 127 + .await 128 + .unwrap_err(); 129 + assert!(err.to_string().contains("AT-URI")); 130 + } 131 + 132 + #[tokio::test] 133 + async fn pds_404_on_fetch() { 134 + let uri = format!("at://{TEST_DID}/app.opake.cloud.directory/gone"); 135 + let mock = MockTransport::new(); 136 + mock.enqueue(HttpResponse { 137 + status: 404, 138 + body: br#"{"error":"RecordNotFound","message":"no such record"}"#.to_vec(), 139 + }); 140 + 141 + let mut client = mock_client(mock); 142 + let err = delete_directory(&mut client, &uri).await.unwrap_err(); 143 + assert!(matches!(err, Error::NotFound(_))); 144 + } 145 + }
+213
crates/opake-core/src/directories/entries.rs
··· 1 + use log::debug; 2 + 3 + use crate::atproto; 4 + use crate::client::{Transport, XrpcClient}; 5 + use crate::error::Error; 6 + use crate::records::{self, Directory}; 7 + 8 + use super::DIRECTORY_COLLECTION; 9 + 10 + /// Add a child entry to a directory (fetch-modify-put). 11 + /// 12 + /// Rejects duplicates. Appends to the end of the entries list. 13 + pub async fn add_entry( 14 + client: &mut XrpcClient<impl Transport>, 15 + directory_uri: &str, 16 + entry_uri: &str, 17 + modified_at: &str, 18 + ) -> Result<(), Error> { 19 + let at_uri = atproto::parse_at_uri(directory_uri)?; 20 + 21 + debug!("fetching directory {}", directory_uri); 22 + let entry = client 23 + .get_record(&at_uri.authority, &at_uri.collection, &at_uri.rkey) 24 + .await?; 25 + 26 + let mut directory: Directory = serde_json::from_value(entry.value)?; 27 + records::check_version(directory.version)?; 28 + 29 + if directory.entries.iter().any(|e| e == entry_uri) { 30 + return Err(Error::InvalidRecord(format!( 31 + "{entry_uri} is already in this directory" 32 + ))); 33 + } 34 + 35 + debug!("adding entry {}", entry_uri); 36 + directory.entries.push(entry_uri.to_string()); 37 + directory.modified_at = Some(modified_at.to_string()); 38 + 39 + client 40 + .put_record(DIRECTORY_COLLECTION, &at_uri.rkey, &directory) 41 + .await?; 42 + 43 + Ok(()) 44 + } 45 + 46 + /// Remove a child entry from a directory (fetch-modify-put). 47 + /// 48 + /// Errors if the entry is not present. 49 + pub async fn remove_entry( 50 + client: &mut XrpcClient<impl Transport>, 51 + directory_uri: &str, 52 + entry_uri: &str, 53 + modified_at: &str, 54 + ) -> Result<(), Error> { 55 + let at_uri = atproto::parse_at_uri(directory_uri)?; 56 + 57 + debug!("fetching directory {}", directory_uri); 58 + let entry = client 59 + .get_record(&at_uri.authority, &at_uri.collection, &at_uri.rkey) 60 + .await?; 61 + 62 + let mut directory: Directory = serde_json::from_value(entry.value)?; 63 + records::check_version(directory.version)?; 64 + 65 + let original_len = directory.entries.len(); 66 + directory.entries.retain(|e| e != entry_uri); 67 + 68 + if directory.entries.len() == original_len { 69 + return Err(Error::NotFound(format!( 70 + "{entry_uri} not found in directory" 71 + ))); 72 + } 73 + 74 + debug!("removed entry {}", entry_uri); 75 + directory.modified_at = Some(modified_at.to_string()); 76 + 77 + client 78 + .put_record(DIRECTORY_COLLECTION, &at_uri.rkey, &directory) 79 + .await?; 80 + 81 + Ok(()) 82 + } 83 + 84 + #[cfg(test)] 85 + mod tests { 86 + use super::*; 87 + use crate::client::RequestBody; 88 + use crate::records::{Directory, SCHEMA_VERSION}; 89 + use crate::test_utils::MockTransport; 90 + 91 + use super::super::tests::{ 92 + dummy_directory_with_entries, get_record_response, mock_client, put_record_response, 93 + }; 94 + 95 + const DIR_URI: &str = "at://did:plc:test/app.opake.cloud.directory/dir1"; 96 + const DOC_URI: &str = "at://did:plc:test/app.opake.cloud.document/doc1"; 97 + const DOC_URI_2: &str = "at://did:plc:test/app.opake.cloud.document/doc2"; 98 + 99 + #[tokio::test] 100 + async fn add_entry_happy_path() { 101 + let directory = dummy_directory_with_entries("/", vec![]); 102 + let mock = MockTransport::new(); 103 + mock.enqueue(get_record_response(DIR_URI, &directory)); 104 + mock.enqueue(put_record_response(DIR_URI)); 105 + 106 + let mut client = mock_client(mock.clone()); 107 + add_entry(&mut client, DIR_URI, DOC_URI, "2026-03-01T12:00:00Z") 108 + .await 109 + .unwrap(); 110 + 111 + let reqs = mock.requests(); 112 + assert_eq!(reqs.len(), 2); 113 + assert!(reqs[0].url.contains("getRecord")); 114 + assert!(reqs[1].url.contains("putRecord")); 115 + 116 + match &reqs[1].body { 117 + Some(RequestBody::Json(v)) => { 118 + let updated: Directory = serde_json::from_value(v["record"].clone()).unwrap(); 119 + assert_eq!(updated.entries, vec![DOC_URI]); 120 + assert_eq!(updated.modified_at.unwrap(), "2026-03-01T12:00:00Z"); 121 + } 122 + _ => panic!("expected JSON body"), 123 + } 124 + } 125 + 126 + #[tokio::test] 127 + async fn add_entry_rejects_duplicate() { 128 + let directory = dummy_directory_with_entries("/", vec![DOC_URI.into()]); 129 + let mock = MockTransport::new(); 130 + mock.enqueue(get_record_response(DIR_URI, &directory)); 131 + 132 + let mut client = mock_client(mock); 133 + let err = add_entry(&mut client, DIR_URI, DOC_URI, "2026-03-01T12:00:00Z") 134 + .await 135 + .unwrap_err(); 136 + 137 + assert!(err.to_string().contains("already in this directory")); 138 + } 139 + 140 + #[tokio::test] 141 + async fn add_entry_rejects_future_version() { 142 + let mut directory = dummy_directory_with_entries("/", vec![]); 143 + directory.version = SCHEMA_VERSION + 1; 144 + 145 + let mock = MockTransport::new(); 146 + mock.enqueue(get_record_response(DIR_URI, &directory)); 147 + 148 + let mut client = mock_client(mock); 149 + let err = add_entry(&mut client, DIR_URI, DOC_URI, "2026-03-01T12:00:00Z") 150 + .await 151 + .unwrap_err(); 152 + 153 + assert!(err.to_string().contains("schema version")); 154 + } 155 + 156 + #[tokio::test] 157 + async fn remove_entry_happy_path() { 158 + let directory = dummy_directory_with_entries("/", vec![DOC_URI.into(), DOC_URI_2.into()]); 159 + let mock = MockTransport::new(); 160 + mock.enqueue(get_record_response(DIR_URI, &directory)); 161 + mock.enqueue(put_record_response(DIR_URI)); 162 + 163 + let mut client = mock_client(mock.clone()); 164 + remove_entry(&mut client, DIR_URI, DOC_URI, "2026-03-01T12:00:00Z") 165 + .await 166 + .unwrap(); 167 + 168 + let reqs = mock.requests(); 169 + match &reqs[1].body { 170 + Some(RequestBody::Json(v)) => { 171 + let updated: Directory = serde_json::from_value(v["record"].clone()).unwrap(); 172 + assert_eq!(updated.entries, vec![DOC_URI_2]); 173 + assert!(updated.modified_at.is_some()); 174 + } 175 + _ => panic!("expected JSON body"), 176 + } 177 + } 178 + 179 + #[tokio::test] 180 + async fn remove_entry_not_found() { 181 + let directory = dummy_directory_with_entries("/", vec![DOC_URI.into()]); 182 + let mock = MockTransport::new(); 183 + mock.enqueue(get_record_response(DIR_URI, &directory)); 184 + 185 + let mut client = mock_client(mock); 186 + let err = remove_entry( 187 + &mut client, 188 + DIR_URI, 189 + "at://did:plc:test/app.opake.cloud.document/nope", 190 + "2026-03-01T12:00:00Z", 191 + ) 192 + .await 193 + .unwrap_err(); 194 + 195 + assert!(matches!(err, Error::NotFound(_))); 196 + } 197 + 198 + #[tokio::test] 199 + async fn remove_entry_rejects_future_version() { 200 + let mut directory = dummy_directory_with_entries("/", vec![DOC_URI.into()]); 201 + directory.version = SCHEMA_VERSION + 1; 202 + 203 + let mock = MockTransport::new(); 204 + mock.enqueue(get_record_response(DIR_URI, &directory)); 205 + 206 + let mut client = mock_client(mock); 207 + let err = remove_entry(&mut client, DIR_URI, DOC_URI, "2026-03-01T12:00:00Z") 208 + .await 209 + .unwrap_err(); 210 + 211 + assert!(err.to_string().contains("schema version")); 212 + } 213 + }
+114
crates/opake-core/src/directories/get_or_create_root.rs
··· 1 + use log::debug; 2 + 3 + use crate::client::{Transport, XrpcClient}; 4 + use crate::error::Error; 5 + use crate::records::Directory; 6 + 7 + use super::{DIRECTORY_COLLECTION, ROOT_DIRECTORY_NAME, ROOT_DIRECTORY_RKEY}; 8 + 9 + /// Get the root directory's AT-URI, creating it if it doesn't exist. 10 + /// 11 + /// The root directory is a singleton at rkey "self" with name "/". 12 + /// Uses `put_record` for creation (idempotent upsert with explicit rkey). 13 + pub async fn get_or_create_root( 14 + client: &mut XrpcClient<impl Transport>, 15 + did: &str, 16 + created_at: &str, 17 + ) -> Result<String, Error> { 18 + debug!("checking for root directory"); 19 + match client 20 + .get_record(did, DIRECTORY_COLLECTION, ROOT_DIRECTORY_RKEY) 21 + .await 22 + { 23 + Ok(entry) => { 24 + debug!("root directory exists: {}", entry.uri); 25 + Ok(entry.uri) 26 + } 27 + Err(Error::NotFound(_)) => { 28 + debug!("root directory not found, creating"); 29 + let root = Directory::new(ROOT_DIRECTORY_NAME.to_string(), created_at.to_string()); 30 + let record_ref = client 31 + .put_record(DIRECTORY_COLLECTION, ROOT_DIRECTORY_RKEY, &root) 32 + .await?; 33 + Ok(record_ref.uri) 34 + } 35 + Err(e) => Err(e), 36 + } 37 + } 38 + 39 + #[cfg(test)] 40 + mod tests { 41 + use super::*; 42 + use crate::client::RequestBody; 43 + use crate::records::Directory; 44 + use crate::test_utils::MockTransport; 45 + 46 + use super::super::tests::{ 47 + dummy_directory, get_record_response, mock_client, not_found_response, put_record_response, 48 + TEST_DID, 49 + }; 50 + 51 + const ROOT_URI: &str = "at://did:plc:test/app.opake.cloud.directory/self"; 52 + 53 + #[tokio::test] 54 + async fn returns_existing_root() { 55 + let root = dummy_directory("/"); 56 + let mock = MockTransport::new(); 57 + mock.enqueue(get_record_response(ROOT_URI, &root)); 58 + 59 + let mut client = mock_client(mock.clone()); 60 + let uri = get_or_create_root(&mut client, TEST_DID, "2026-03-01T00:00:00Z") 61 + .await 62 + .unwrap(); 63 + 64 + assert_eq!(uri, ROOT_URI); 65 + 66 + let reqs = mock.requests(); 67 + assert_eq!(reqs.len(), 1); 68 + assert!(reqs[0].url.contains("getRecord")); 69 + } 70 + 71 + #[tokio::test] 72 + async fn creates_root_on_404() { 73 + let mock = MockTransport::new(); 74 + mock.enqueue(not_found_response()); 75 + mock.enqueue(put_record_response(ROOT_URI)); 76 + 77 + let mut client = mock_client(mock.clone()); 78 + let uri = get_or_create_root(&mut client, TEST_DID, "2026-03-01T00:00:00Z") 79 + .await 80 + .unwrap(); 81 + 82 + assert_eq!(uri, ROOT_URI); 83 + 84 + let reqs = mock.requests(); 85 + assert_eq!(reqs.len(), 2); 86 + assert!(reqs[0].url.contains("getRecord")); 87 + assert!(reqs[1].url.contains("putRecord")); 88 + 89 + match &reqs[1].body { 90 + Some(RequestBody::Json(v)) => { 91 + assert_eq!(v["rkey"], "self"); 92 + let record: Directory = serde_json::from_value(v["record"].clone()).unwrap(); 93 + assert_eq!(record.name, "/"); 94 + assert!(record.entries.is_empty()); 95 + } 96 + _ => panic!("expected JSON body"), 97 + } 98 + } 99 + 100 + #[tokio::test] 101 + async fn propagates_non_404_errors() { 102 + let mock = MockTransport::new(); 103 + mock.enqueue(crate::client::HttpResponse { 104 + status: 500, 105 + body: br#"{"error":"InternalServerError","message":"boom"}"#.to_vec(), 106 + }); 107 + 108 + let mut client = mock_client(mock); 109 + let err = get_or_create_root(&mut client, TEST_DID, "2026-03-01T00:00:00Z") 110 + .await 111 + .unwrap_err(); 112 + assert!(matches!(err, Error::Xrpc { .. })); 113 + } 114 + }
+147
crates/opake-core/src/directories/list.rs
··· 1 + use crate::client::{list_collection, Transport, XrpcClient}; 2 + use crate::error::Error; 3 + use crate::records::Directory; 4 + 5 + use super::DIRECTORY_COLLECTION; 6 + 7 + /// A directory listing entry with its AT-URI and parsed metadata. 8 + #[derive(Debug)] 9 + pub struct DirectoryEntry { 10 + pub uri: String, 11 + pub name: String, 12 + pub entry_count: usize, 13 + pub created_at: String, 14 + } 15 + 16 + /// Fetch all directory records, paginating through the full collection. 17 + /// Silently skips records that can't be parsed or have an unsupported 18 + /// schema version. 19 + pub async fn list_directories( 20 + client: &mut XrpcClient<impl Transport>, 21 + ) -> Result<Vec<DirectoryEntry>, Error> { 22 + list_collection(client, DIRECTORY_COLLECTION, |uri, directory: Directory| { 23 + DirectoryEntry { 24 + uri: uri.to_owned(), 25 + name: directory.name, 26 + entry_count: directory.entries.len(), 27 + created_at: directory.created_at, 28 + } 29 + }) 30 + .await 31 + } 32 + 33 + #[cfg(test)] 34 + mod tests { 35 + use super::*; 36 + use crate::client::HttpResponse; 37 + use crate::records; 38 + use crate::test_utils::MockTransport; 39 + 40 + use super::super::tests::{ 41 + dummy_directory, dummy_directory_with_entries, list_records_response, mock_client, 42 + }; 43 + 44 + #[tokio::test] 45 + async fn single_directory() { 46 + let mock = MockTransport::new(); 47 + mock.enqueue(list_records_response( 48 + &[("dir1", dummy_directory("Photos"))], 49 + None, 50 + )); 51 + 52 + let mut client = mock_client(mock.clone()); 53 + let entries = list_directories(&mut client).await.unwrap(); 54 + 55 + assert_eq!(entries.len(), 1); 56 + assert_eq!(entries[0].name, "Photos"); 57 + assert_eq!(entries[0].entry_count, 0); 58 + assert!(entries[0].uri.contains("dir1")); 59 + 60 + let reqs = mock.requests(); 61 + assert!(reqs[0].url.contains("app.opake.cloud.directory")); 62 + } 63 + 64 + #[tokio::test] 65 + async fn multiple_directories() { 66 + let docs = vec![ 67 + ("dir1", dummy_directory("Photos")), 68 + ( 69 + "dir2", 70 + dummy_directory_with_entries( 71 + "Documents", 72 + vec!["at://did:plc:test/app.opake.cloud.document/a".into()], 73 + ), 74 + ), 75 + ]; 76 + let mock = MockTransport::new(); 77 + mock.enqueue(list_records_response(&docs, None)); 78 + 79 + let mut client = mock_client(mock); 80 + let entries = list_directories(&mut client).await.unwrap(); 81 + 82 + assert_eq!(entries.len(), 2); 83 + assert_eq!(entries[0].name, "Photos"); 84 + assert_eq!(entries[0].entry_count, 0); 85 + assert_eq!(entries[1].name, "Documents"); 86 + assert_eq!(entries[1].entry_count, 1); 87 + } 88 + 89 + #[tokio::test] 90 + async fn paginates() { 91 + let mock = MockTransport::new(); 92 + mock.enqueue(list_records_response( 93 + &[("d1", dummy_directory("First"))], 94 + Some("cursor-1"), 95 + )); 96 + mock.enqueue(list_records_response( 97 + &[("d2", dummy_directory("Second"))], 98 + None, 99 + )); 100 + 101 + let mut client = mock_client(mock.clone()); 102 + let entries = list_directories(&mut client).await.unwrap(); 103 + 104 + assert_eq!(entries.len(), 2); 105 + assert_eq!(entries[0].name, "First"); 106 + assert_eq!(entries[1].name, "Second"); 107 + 108 + let reqs = mock.requests(); 109 + assert!(reqs[1].url.contains("cursor=cursor-1")); 110 + } 111 + 112 + #[tokio::test] 113 + async fn empty_collection() { 114 + let mock = MockTransport::new(); 115 + mock.enqueue(list_records_response(&[], None)); 116 + 117 + let mut client = mock_client(mock); 118 + let entries = list_directories(&mut client).await.unwrap(); 119 + assert!(entries.is_empty()); 120 + } 121 + 122 + #[tokio::test] 123 + async fn skips_future_version() { 124 + let mut directory = dummy_directory("Future"); 125 + directory.version = records::SCHEMA_VERSION + 1; 126 + 127 + let mock = MockTransport::new(); 128 + mock.enqueue(list_records_response(&[("d1", directory)], None)); 129 + 130 + let mut client = mock_client(mock); 131 + let entries = list_directories(&mut client).await.unwrap(); 132 + assert!(entries.is_empty()); 133 + } 134 + 135 + #[tokio::test] 136 + async fn pds_error_propagates() { 137 + let mock = MockTransport::new(); 138 + mock.enqueue(HttpResponse { 139 + status: 500, 140 + body: br#"{"error":"InternalServerError","message":"boom"}"#.to_vec(), 141 + }); 142 + 143 + let mut client = mock_client(mock); 144 + let err = list_directories(&mut client).await.unwrap_err(); 145 + assert!(matches!(err, Error::Xrpc { .. })); 146 + } 147 + }
+120
crates/opake-core/src/directories/mod.rs
··· 1 + // Directory operations: create, list, delete, manage entries. 2 + // 3 + // Directories are purely organizational — no crypto, no encryption. They 4 + // own their children via an ordered AT-URI array (children-on-parent model). 5 + // The root directory is a lazy-created singleton at rkey "self". 6 + 7 + mod create; 8 + mod delete; 9 + mod entries; 10 + mod get_or_create_root; 11 + mod list; 12 + 13 + pub use create::create_directory; 14 + pub use delete::delete_directory; 15 + pub use entries::{add_entry, remove_entry}; 16 + pub use get_or_create_root::get_or_create_root; 17 + pub use list::{list_directories, DirectoryEntry}; 18 + 19 + pub const DIRECTORY_COLLECTION: &str = "app.opake.cloud.directory"; 20 + pub const ROOT_DIRECTORY_RKEY: &str = "self"; 21 + pub const ROOT_DIRECTORY_NAME: &str = "/"; 22 + 23 + #[cfg(test)] 24 + pub(crate) mod tests { 25 + use crate::client::{HttpResponse, Session, XrpcClient}; 26 + use crate::records::Directory; 27 + use crate::test_utils::MockTransport; 28 + 29 + use super::*; 30 + 31 + pub const TEST_DID: &str = "did:plc:test"; 32 + 33 + pub fn mock_client(mock: MockTransport) -> XrpcClient<MockTransport> { 34 + let session = Session { 35 + did: TEST_DID.into(), 36 + handle: "test.handle".into(), 37 + access_jwt: "test-jwt".into(), 38 + refresh_jwt: "test-refresh".into(), 39 + }; 40 + XrpcClient::with_session(mock, "https://pds.test".into(), session) 41 + } 42 + 43 + pub fn dummy_directory(name: &str) -> Directory { 44 + Directory::new(name.into(), "2026-03-01T00:00:00Z".into()) 45 + } 46 + 47 + pub fn dummy_directory_with_entries(name: &str, entries: Vec<String>) -> Directory { 48 + Directory { 49 + entries, 50 + ..Directory::new(name.into(), "2026-03-01T00:00:00Z".into()) 51 + } 52 + } 53 + 54 + pub fn create_record_response(uri: &str) -> HttpResponse { 55 + HttpResponse { 56 + status: 200, 57 + body: serde_json::to_vec(&serde_json::json!({ 58 + "uri": uri, 59 + "cid": "bafydirectory", 60 + })) 61 + .unwrap(), 62 + } 63 + } 64 + 65 + pub fn put_record_response(uri: &str) -> HttpResponse { 66 + HttpResponse { 67 + status: 200, 68 + body: serde_json::to_vec(&serde_json::json!({ 69 + "uri": uri, 70 + "cid": "bafyupdated", 71 + })) 72 + .unwrap(), 73 + } 74 + } 75 + 76 + pub fn get_record_response(uri: &str, directory: &Directory) -> HttpResponse { 77 + HttpResponse { 78 + status: 200, 79 + body: serde_json::to_vec(&serde_json::json!({ 80 + "uri": uri, 81 + "cid": "bafydirectory", 82 + "value": directory, 83 + })) 84 + .unwrap(), 85 + } 86 + } 87 + 88 + pub fn list_records_response( 89 + directories: &[(&str, Directory)], 90 + cursor: Option<&str>, 91 + ) -> HttpResponse { 92 + let records: Vec<serde_json::Value> = directories 93 + .iter() 94 + .map(|(rkey, dir)| { 95 + serde_json::json!({ 96 + "uri": format!("at://{TEST_DID}/{DIRECTORY_COLLECTION}/{rkey}"), 97 + "cid": "bafydirectory", 98 + "value": dir, 99 + }) 100 + }) 101 + .collect(); 102 + 103 + let mut body = serde_json::json!({ "records": records }); 104 + if let Some(c) = cursor { 105 + body["cursor"] = serde_json::Value::String(c.into()); 106 + } 107 + 108 + HttpResponse { 109 + status: 200, 110 + body: serde_json::to_vec(&body).unwrap(), 111 + } 112 + } 113 + 114 + pub fn not_found_response() -> HttpResponse { 115 + HttpResponse { 116 + status: 404, 117 + body: br#"{"error":"RecordNotFound","message":"no such record"}"#.to_vec(), 118 + } 119 + } 120 + }
+1
crates/opake-core/src/lib.rs
··· 17 17 pub mod atproto; 18 18 pub mod client; 19 19 pub mod crypto; 20 + pub mod directories; 20 21 pub mod documents; 21 22 pub mod error; 22 23 pub mod keyrings;
+28
crates/opake-core/src/records/directory.rs
··· 1 + use serde::{Deserialize, Serialize}; 2 + 3 + use super::{default_version, SCHEMA_VERSION}; 4 + 5 + #[derive(Debug, Clone, Serialize, Deserialize)] 6 + #[serde(rename_all = "camelCase")] 7 + pub struct Directory { 8 + #[serde(default = "default_version")] 9 + pub version: u32, 10 + pub name: String, 11 + #[serde(default, skip_serializing_if = "Vec::is_empty")] 12 + pub entries: Vec<String>, 13 + pub created_at: String, 14 + #[serde(skip_serializing_if = "Option::is_none")] 15 + pub modified_at: Option<String>, 16 + } 17 + 18 + impl Directory { 19 + pub fn new(name: String, created_at: String) -> Self { 20 + Self { 21 + version: SCHEMA_VERSION, 22 + name, 23 + entries: Vec::new(), 24 + created_at, 25 + modified_at: None, 26 + } 27 + } 28 + }
+2 -5
crates/opake-core/src/records/document.rs
··· 43 43 #[serde(default, skip_serializing_if = "Vec::is_empty")] 44 44 pub tags: Vec<String>, 45 45 #[serde(skip_serializing_if = "Option::is_none")] 46 - pub parent: Option<String>, 47 - #[serde(skip_serializing_if = "Option::is_none")] 48 46 pub description: Option<String>, 49 47 #[serde(skip_serializing_if = "Option::is_none")] 50 48 pub visibility: Option<String>, ··· 55 53 56 54 impl Document { 57 55 /// Construct a new document with the current schema version and sensible 58 - /// defaults for optional fields. Callers set tags/parent/description/etc. 59 - /// via struct update syntax: `Document::new(..) { tags, ..Document::new(..) }` 56 + /// defaults for optional fields. Callers set tags/description/etc. via 57 + /// struct update syntax: `Document::new(..) { tags, ..Document::new(..) }` 60 58 pub fn new(name: String, blob: BlobRef, encryption: Encryption, created_at: String) -> Self { 61 59 Self { 62 60 version: SCHEMA_VERSION, ··· 66 64 blob, 67 65 encryption, 68 66 tags: Vec::new(), 69 - parent: None, 70 67 description: None, 71 68 visibility: None, 72 69 created_at,
+44 -1
crates/opake-core/src/records/mod.rs
··· 7 7 // `atproto` module. The ones used as record fields are re-exported here. 8 8 9 9 mod defs; 10 + mod directory; 10 11 mod document; 11 12 mod grant; 12 13 mod keyring; ··· 20 21 21 22 // Re-export all record types at the `records::` level. 22 23 pub use defs::{EncryptionEnvelope, KeyringRef, WrappedKey}; 24 + pub use directory::Directory; 23 25 pub use document::{DirectEncryption, Document, Encryption, KeyringEncryption}; 24 26 pub use grant::Grant; 25 27 pub use keyring::{KeyHistoryEntry, Keyring}; ··· 42 44 }; 43 45 } 44 46 45 - impl_versioned!(Document, PublicKeyRecord, Grant, Keyring); 47 + impl_versioned!(Directory, Document, PublicKeyRecord, Grant, Keyring); 46 48 47 49 fn default_version() -> u32 { 48 50 SCHEMA_VERSION ··· 109 111 let json = serde_json::to_value(&record).unwrap(); 110 112 // atproto $bytes convention: { "$bytes": "<base64>" } 111 113 assert!(json["publicKey"]["$bytes"].is_string()); 114 + } 115 + 116 + #[test] 117 + fn directory_roundtrips_through_json() { 118 + let directory = Directory::new("/".into(), "2026-03-01T00:00:00Z".into()); 119 + let json = serde_json::to_string(&directory).unwrap(); 120 + let parsed: Directory = serde_json::from_str(&json).unwrap(); 121 + 122 + assert_eq!(parsed.version, SCHEMA_VERSION); 123 + assert_eq!(parsed.name, "/"); 124 + assert!(parsed.entries.is_empty()); 125 + assert_eq!(parsed.created_at, "2026-03-01T00:00:00Z"); 126 + assert!(parsed.modified_at.is_none()); 127 + } 128 + 129 + #[test] 130 + fn directory_entries_omitted_when_empty() { 131 + let directory = Directory::new("Photos".into(), "2026-03-01T00:00:00Z".into()); 132 + let json = serde_json::to_value(&directory).unwrap(); 133 + assert!( 134 + json.get("entries").is_none(), 135 + "empty entries should be omitted from serialization" 136 + ); 137 + } 138 + 139 + #[test] 140 + fn directory_with_entries_roundtrips() { 141 + let mut directory = Directory::new("Photos".into(), "2026-03-01T00:00:00Z".into()); 142 + directory.entries = vec![ 143 + "at://did:plc:test/app.opake.cloud.document/abc".into(), 144 + "at://did:plc:test/app.opake.cloud.directory/def".into(), 145 + ]; 146 + directory.modified_at = Some("2026-03-01T12:00:00Z".into()); 147 + 148 + let json = serde_json::to_string(&directory).unwrap(); 149 + let parsed: Directory = serde_json::from_str(&json).unwrap(); 150 + 151 + assert_eq!(parsed.entries.len(), 2); 152 + assert!(parsed.entries[0].contains("document")); 153 + assert!(parsed.entries[1].contains("directory")); 154 + assert_eq!(parsed.modified_at.unwrap(), "2026-03-01T12:00:00Z"); 112 155 } 113 156 114 157 #[test]
+41 -3
lexicons/EXAMPLES.md
··· 16 16 17 17 This record uses rkey `self` (like `app.bsky.actor.profile`) — there's only one per account. The key is published automatically on `opake login`. 18 18 19 - ## 1. Alice creates a private encrypted document 19 + ## 1. Root directory (created on first `opake mkdir`) 20 + 21 + The root directory is a singleton at rkey `self`. It's lazy-created the first time a user creates a directory. 22 + 23 + ```json 24 + { 25 + "$type": "app.opake.cloud.directory", 26 + "version": 1, 27 + "name": "/", 28 + "entries": [ 29 + "at://did:plc:alice123/app.opake.cloud.directory/3k..." 30 + ], 31 + "createdAt": "2026-03-01T10:00:00.000Z", 32 + "modifiedAt": "2026-03-01T10:00:00.000Z" 33 + } 34 + ``` 35 + 36 + Directories are purely organizational — no encryption, no crypto. The `entries` array is an ordered list of AT-URIs pointing to documents or other directories (children-on-parent model). You can derive the child type from the collection segment of the URI. 37 + 38 + ## 2. A named directory 39 + 40 + ```json 41 + { 42 + "$type": "app.opake.cloud.directory", 43 + "version": 1, 44 + "name": "Photos", 45 + "entries": [ 46 + "at://did:plc:alice123/app.opake.cloud.document/3kabcd", 47 + "at://did:plc:alice123/app.opake.cloud.document/3kefgh", 48 + "at://did:plc:alice123/app.opake.cloud.directory/3kijkl" 49 + ], 50 + "createdAt": "2026-03-01T10:05:00.000Z", 51 + "modifiedAt": "2026-03-01T11:30:00.000Z" 52 + } 53 + ``` 54 + 55 + This directory contains two documents and a subdirectory. Non-root directories use TID rkeys (created via `createRecord`). 56 + 57 + ## 3. Alice creates a private encrypted document 20 58 21 59 ```json 22 60 { ··· 56 94 can decrypt. 57 95 58 96 59 - ## 2. Alice shares the document with Bob via a grant 97 + ## 4. Alice shares the document with Bob via a grant 60 98 61 99 ```json 62 100 { ··· 87 125 the document with a fresh content key. 88 126 89 127 90 - ## 3. Keyring-based group sharing (family photos) 128 + ## 5. Keyring-based group sharing (family photos) 91 129 92 130 ### First, the keyring: 93 131
+1
lexicons/README.md
··· 15 15 | NSID | Type | Purpose | 16 16 |------|------|---------| 17 17 | `app.opake.cloud.defs` | defs | Shared type definitions (encryption envelope, wrapped key, etc.) | 18 + | `app.opake.cloud.directory` | record | A directory containing an ordered list of child document/directory AT-URIs | 18 19 | `app.opake.cloud.document` | record | An encrypted file/document with metadata | 19 20 | `app.opake.cloud.publicKey` | record | Singleton X25519 encryption public key (rkey: `self`) for key discovery | 20 21 | `app.opake.cloud.keyring` | record | A named group with a shared symmetric key, wrapped to each member |
+35
lexicons/app.opake.cloud.directory.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "app.opake.cloud.directory", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "A directory in the personal cloud. Contains an ordered list of AT-URIs referencing documents or other directories. The root directory is a singleton at rkey 'self'.", 8 + "key": "any", 9 + "record": { 10 + "type": "object", 11 + "required": ["version", "name", "createdAt"], 12 + "properties": { 13 + "version": { 14 + "type": "integer", 15 + "description": "Schema version for the app.opake.cloud.* namespace.", 16 + "minimum": 1 17 + }, 18 + "name": { 19 + "type": "string", 20 + "description": "Human-readable directory name. '/' for root.", 21 + "maxLength": 512 22 + }, 23 + "entries": { 24 + "type": "array", 25 + "description": "Ordered list of AT-URIs of child documents or directories.", 26 + "items": { "type": "string", "format": "at-uri" }, 27 + "maxLength": 10000 28 + }, 29 + "createdAt": { "type": "string", "format": "datetime" }, 30 + "modifiedAt": { "type": "string", "format": "datetime" } 31 + } 32 + } 33 + } 34 + } 35 + }
-5
lexicons/app.opake.cloud.document.json
··· 50 50 "items": { "type": "string", "maxLength": 128 }, 51 51 "maxLength": 32 52 52 }, 53 - "parent": { 54 - "type": "string", 55 - "format": "at-uri", 56 - "description": "Optional reference to a parent document (for folder-like hierarchy). Points to another app.opake.cloud.document record." 57 - }, 58 53 "description": { 59 54 "type": "string", 60 55 "description": "Optional plaintext description or summary.",