An encrypted personal cloud built on the AT Protocol.

Rename app.opake.cloud.* → app.opake.* and version → opakeVersion

Drop the unnecessary `cloud` segment from all collection NSIDs and
rename the schema `version` field to `opakeVersion` for clarity before
launch. Purely mechanical — zero behavioral change.

Closes #186

+331 -348
+4 -4
AGENT-BLACKBOX-TEST.md
··· 88 88 echo "hello opake" > /tmp/test-direct.txt 89 89 90 90 A$ opake upload /tmp/test-direct.txt 91 - # prints: test-direct.txt → at://did:plc:A/app.opake.cloud.document/<rkey> 91 + # prints: test-direct.txt → at://did:plc:A/app.opake.document/<rkey> 92 92 ``` 93 93 94 94 Save the output AT-URI as `$DOC_URI`. ··· 158 158 159 159 ```bash 160 160 A$ opake mkdir Photos 161 - # prints: Photos → at://did:plc:A/app.opake.cloud.directory/<rkey> 161 + # prints: Photos → at://did:plc:A/app.opake.directory/<rkey> 162 162 163 163 A$ opake mkdir Archive 164 164 ``` ··· 317 317 318 318 ```bash 319 319 A$ opake share shared-file.txt <B-handle> --note "for your eyes only" 320 - # prints: shared with <B-handle> → at://did:plc:A/app.opake.cloud.grant/<grant-rkey> 320 + # prints: shared with <B-handle> → at://did:plc:A/app.opake.grant/<grant-rkey> 321 321 ``` 322 322 323 323 Save grant URI as `$GRANT_URI`. ··· 376 376 377 377 ```bash 378 378 A$ opake keyring create family-photos 379 - # prints: family-photos → at://did:plc:A/app.opake.cloud.keyring/<kr-rkey> 379 + # prints: family-photos → at://did:plc:A/app.opake.keyring/<kr-rkey> 380 380 ``` 381 381 382 382 ### 6.2 List keyrings
+1
CHANGELOG.md
··· 66 66 - Fix missing HTTP status checks in XRPC client [#104](https://issues.opake.app/issues/104.html) 67 67 68 68 ### Changed 69 + - Rename collection NSIDs from app.opake.cloud.* to app.opake.* and version to opakeVersion [#186](https://issues.opake.app/issues/186.html) 69 70 - Add browser key storage with IndexedDB and Web Crypto API [#160](https://issues.opake.app/issues/160.html) 70 71 - Add inbox command for grant discovery via AppView [#162](https://issues.opake.app/issues/162.html) 71 72 - Port Figma Make cabinet design into web frontend [#165](https://issues.opake.app/issues/165.html)
+2 -2
CLAUDE.md
··· 10 10 11 11 ## Why atproto? 12 12 13 - A PDS is essentially cloud storage with an API. It stores signed, schema-validated records in a Merkle Search Tree, plus binary blobs. It doesn't understand or inspect the data — it just manages it. We define custom lexicons under `app.opake.cloud.*` to give structure to our files, encryption metadata, and sharing grants. The PDS handles identity (DID-based), authentication, blob storage (up to 50MB default), federation, and sync — all for free. 13 + A PDS is essentially cloud storage with an API. It stores signed, schema-validated records in a Merkle Search Tree, plus binary blobs. It doesn't understand or inspect the data — it just manages it. We define custom lexicons under `app.opake.*` to give structure to our files, encryption metadata, and sharing grants. The PDS handles identity (DID-based), authentication, blob storage (up to 50MB default), federation, and sync — all for free. 14 14 15 15 The PDS is external. It's already running. This project talks to it over XRPC. 16 16 ··· 21 21 3. **Two-layer key for keyrings.** Per-document content key wrapped under group key. Rotating the group key doesn't require re-encrypting blobs. 22 22 4. **No revocation guarantee for historical access.** Same as git-crypt. True revocation requires re-encrypting the blob with a new content key. 23 23 5. **Plaintext metadata is opt-in transparency.** Names and tags unencrypted by default for AppView indexing. Full opacity via dummy values + encrypted metadata payload. 24 - 6. **Public keys as PDS records.** atproto DID docs only have signing keys. Opake publishes X25519 encryption public keys as `app.opake.cloud.publicKey/self` singleton records. 24 + 6. **Public keys as PDS records.** atproto DID docs only have signing keys. Opake publishes X25519 encryption public keys as `app.opake.publicKey/self` singleton records. 25 25 7. **Multi-device: seed phrase** (future). MVP uses plaintext keypair at `~/.config/opake/accounts/<did>/identity.json`. 26 26 8. **Storage trait in opake-core.** Config, Identity, Session types and the `Storage` trait live in core so both CLI (`FileStorage`, filesystem) and web (`IndexedDbStorage`, IndexedDB) share the same contract. Platform-specific I/O is injected, never imported. 27 27
+6 -6
README.md
··· 4 4 5 5 An encrypted personal cloud built on the [AT Protocol](https://atproto.com). 6 6 7 - Opake uses your existing PDS as a storage and identity layer. Files are encrypted client-side with AES-256-GCM before upload — the PDS only ever sees ciphertext. Custom lexicons under `app.opake.cloud.*` give structure to documents, encryption metadata, and sharing grants. 7 + Opake uses your existing PDS as a storage and identity layer. Files are encrypted client-side with AES-256-GCM before upload — the PDS only ever sees ciphertext. Custom lexicons under `app.opake.*` give structure to documents, encryption metadata, and sharing grants. 8 8 9 9 Your data is opaque to everyone without the key. That's the point. 10 10 ··· 27 27 → encrypt with random AES-256-GCM key 28 28 → upload ciphertext blob to PDS 29 29 → wrap content key to owner's DID public key 30 - → store metadata as app.opake.cloud.document record 30 + → store metadata as app.opake.document record 31 31 ``` 32 32 33 33 No modifications to the PDS. All crypto happens on your machine. ··· 91 91 opake cat Photos/notes.txt 92 92 93 93 # download a shared file from another user (via grant URI) 94 - opake download --grant at://did:plc:abc/app.opake.cloud.grant/tid123 94 + opake download --grant at://did:plc:abc/app.opake.grant/tid123 95 95 96 96 # delete (supports paths and recursive directory deletion) 97 97 opake rm photo.jpg ··· 113 113 opake shared --long 114 114 115 115 # revoke a share grant 116 - opake revoke at://did:plc:abc/app.opake.cloud.grant/tid123 116 + opake revoke at://did:plc:abc/app.opake.grant/tid123 117 117 118 118 # check incoming grants (via AppView) 119 119 opake inbox --appview https://appview.example.com ··· 124 124 opake keyring ls 125 125 opake keyring add-member family-photos alice.example.com 126 126 opake upload photo.jpg --keyring family-photos 127 - opake download --keyring-member at://did:plc:abc/app.opake.cloud.document/tid456 127 + opake download --keyring-member at://did:plc:abc/app.opake.document/tid456 128 128 opake keyring remove-member family-photos alice.example.com 129 129 130 130 # transfer encryption identity to a new device ··· 165 165 - [x] Asymmetric key wrapping (x25519-hkdf-a256kw) 166 166 - [x] Automatic token refresh 167 167 - [x] Multi-account support (--as flag, logout, set-default, accounts) 168 - - [x] Public key auto-publish on login (app.opake.cloud.publicKey record) 168 + - [x] Public key auto-publish on login (app.opake.publicKey record) 169 169 - [x] DID resolution and public key extraction 170 170 - [x] Direct file sharing between DIDs 171 171 - [x] Cross-PDS shared file download (via --grant flag)
+8 -8
crates/opake-appview/src/api/api_tests.rs
··· 122 122 let state = test_state(); 123 123 124 124 let grant = IndexedGrant { 125 - uri: "at://did:plc:owner/app.opake.cloud.grant/3abc".into(), 125 + uri: "at://did:plc:owner/app.opake.grant/3abc".into(), 126 126 owner_did: "did:plc:owner".into(), 127 127 recipient_did: "did:plc:me".into(), 128 - document_uri: "at://did:plc:owner/app.opake.cloud.document/3xyz".into(), 128 + document_uri: "at://did:plc:owner/app.opake.document/3xyz".into(), 129 129 permissions: Some("read".into()), 130 130 note: Some("test file".into()), 131 131 created_at: "2026-03-01T12:00:00Z".into(), ··· 145 145 assert_eq!(items[0]["ownerDid"], "did:plc:owner"); 146 146 assert_eq!( 147 147 items[0]["documentUri"], 148 - "at://did:plc:owner/app.opake.cloud.document/3xyz" 148 + "at://did:plc:owner/app.opake.document/3xyz" 149 149 ); 150 150 assert_eq!(items[0]["permissions"], "read"); 151 151 assert_eq!(items[0]["note"], "test file"); ··· 157 157 158 158 for i in 0..5 { 159 159 let grant = IndexedGrant { 160 - uri: format!("at://did:plc:owner/app.opake.cloud.grant/{i}"), 160 + uri: format!("at://did:plc:owner/app.opake.grant/{i}"), 161 161 owner_did: "did:plc:owner".into(), 162 162 recipient_did: "did:plc:me".into(), 163 - document_uri: format!("at://did:plc:owner/app.opake.cloud.document/{i}"), 163 + document_uri: format!("at://did:plc:owner/app.opake.document/{i}"), 164 164 permissions: None, 165 165 note: None, 166 166 created_at: "2026-03-01T12:00:00Z".into(), ··· 197 197 .with_conn(|c| { 198 198 db_keyrings::upsert_keyring_members( 199 199 c, 200 - "at://did:plc:owner/app.opake.cloud.keyring/3def", 200 + "at://did:plc:owner/app.opake.keyring/3def", 201 201 "did:plc:owner", 202 202 "family-photos", 203 203 &["did:plc:me".into(), "did:plc:other".into()], ··· 221 221 let state = test_state(); 222 222 223 223 let grant = IndexedGrant { 224 - uri: "at://did:plc:owner/app.opake.cloud.grant/3abc".into(), 224 + uri: "at://did:plc:owner/app.opake.grant/3abc".into(), 225 225 owner_did: "did:plc:owner".into(), 226 226 recipient_did: "did:plc:me".into(), 227 - document_uri: "at://did:plc:owner/app.opake.cloud.document/3xyz".into(), 227 + document_uri: "at://did:plc:owner/app.opake.document/3xyz".into(), 228 228 permissions: None, 229 229 note: None, 230 230 created_at: "2026-03-01T12:00:00Z".into(),
+2 -2
crates/opake-appview/src/api/key_cache.rs
··· 44 44 } 45 45 } 46 46 47 - /// Fetch the Ed25519 signing key from a user's `app.opake.cloud.publicKey/self` record. 47 + /// Fetch the Ed25519 signing key from a user's `app.opake.publicKey/self` record. 48 48 /// 49 49 /// Resolution: DID → DID document → PDS URL → getRecord → signingKey field. 50 50 async fn fetch_signing_key(did: &str) -> Result<VerifyingKey> { ··· 84 84 85 85 // Step 2: Fetch public key record from the user's PDS 86 86 let record_url = format!( 87 - "{}/xrpc/com.atproto.repo.getRecord?repo={}&collection=app.opake.cloud.publicKey&rkey=self", 87 + "{}/xrpc/com.atproto.repo.getRecord?repo={}&collection=app.opake.publicKey&rkey=self", 88 88 pds_url.trim_end_matches('/'), 89 89 did 90 90 );
+11 -11
crates/opake-appview/src/db/db_tests.rs
··· 22 22 fn grant_upsert_and_query() { 23 23 let db = test_db(); 24 24 let grant = make_grant( 25 - "at://did:plc:owner/app.opake.cloud.grant/3abc", 25 + "at://did:plc:owner/app.opake.grant/3abc", 26 26 "did:plc:recipient", 27 27 "did:plc:owner", 28 - "at://did:plc:owner/app.opake.cloud.document/3xyz", 28 + "at://did:plc:owner/app.opake.document/3xyz", 29 29 ); 30 30 31 31 db.with_conn(|c| grants::upsert_grant(c, &grant)).unwrap(); ··· 42 42 fn grant_upsert_overwrites() { 43 43 let db = test_db(); 44 44 let mut grant = make_grant( 45 - "at://did:plc:owner/app.opake.cloud.grant/3abc", 45 + "at://did:plc:owner/app.opake.grant/3abc", 46 46 "did:plc:recipient", 47 47 "did:plc:owner", 48 - "at://did:plc:owner/app.opake.cloud.document/3xyz", 48 + "at://did:plc:owner/app.opake.document/3xyz", 49 49 ); 50 50 db.with_conn(|c| grants::upsert_grant(c, &grant)).unwrap(); 51 51 ··· 63 63 fn grant_delete() { 64 64 let db = test_db(); 65 65 let grant = make_grant( 66 - "at://did:plc:owner/app.opake.cloud.grant/3abc", 66 + "at://did:plc:owner/app.opake.grant/3abc", 67 67 "did:plc:recipient", 68 68 "did:plc:owner", 69 - "at://did:plc:owner/app.opake.cloud.document/3xyz", 69 + "at://did:plc:owner/app.opake.document/3xyz", 70 70 ); 71 71 db.with_conn(|c| grants::upsert_grant(c, &grant)).unwrap(); 72 72 db.with_conn(|c| grants::delete_grant(c, &grant.uri)) ··· 84 84 85 85 for i in 0..5 { 86 86 let grant = IndexedGrant { 87 - uri: format!("at://did:plc:owner/app.opake.cloud.grant/{i}"), 87 + uri: format!("at://did:plc:owner/app.opake.grant/{i}"), 88 88 owner_did: "did:plc:owner".into(), 89 89 recipient_did: "did:plc:me".into(), 90 - document_uri: format!("at://did:plc:owner/app.opake.cloud.document/{i}"), 90 + document_uri: format!("at://did:plc:owner/app.opake.document/{i}"), 91 91 permissions: None, 92 92 note: None, 93 93 created_at: "2026-03-01T12:00:00Z".into(), ··· 121 121 db.with_conn(|c| { 122 122 keyrings::upsert_keyring_members( 123 123 c, 124 - "at://did:plc:owner/app.opake.cloud.keyring/3def", 124 + "at://did:plc:owner/app.opake.keyring/3def", 125 125 "did:plc:owner", 126 126 "family-photos", 127 127 &members, ··· 151 151 #[test] 152 152 fn keyring_update_replaces_members() { 153 153 let db = test_db(); 154 - let uri = "at://did:plc:owner/app.opake.cloud.keyring/3def"; 154 + let uri = "at://did:plc:owner/app.opake.keyring/3def"; 155 155 156 156 // Initially: alice + bob 157 157 db.with_conn(|c| { ··· 195 195 #[test] 196 196 fn keyring_delete() { 197 197 let db = test_db(); 198 - let uri = "at://did:plc:owner/app.opake.cloud.keyring/3def"; 198 + let uri = "at://did:plc:owner/app.opake.keyring/3def"; 199 199 200 200 db.with_conn(|c| { 201 201 keyrings::upsert_keyring_members(
+2 -2
crates/opake-appview/src/firehose/events.rs
··· 20 20 } 21 21 22 22 /// Collections we index. 23 - pub const COLLECTION_GRANT: &str = "app.opake.cloud.grant"; 24 - pub const COLLECTION_KEYRING: &str = "app.opake.cloud.keyring"; 23 + pub const COLLECTION_GRANT: &str = "app.opake.grant"; 24 + pub const COLLECTION_KEYRING: &str = "app.opake.keyring"; 25 25 26 26 /// Parsed event ready for indexing. 27 27 #[derive(Debug)]
+12 -12
crates/opake-appview/src/firehose/events_tests.rs
··· 9 9 "commit": {{ 10 10 "rev": "3l3qo2vutsw2b", 11 11 "operation": "{operation}", 12 - "collection": "app.opake.cloud.grant", 12 + "collection": "app.opake.grant", 13 13 "rkey": "3abc", 14 14 "record": {{ 15 - "version": 1, 16 - "document": "at://did:plc:owner123/app.opake.cloud.document/3xyz", 15 + "opakeVersion": 1, 16 + "document": "at://did:plc:owner123/app.opake.document/3xyz", 17 17 "recipient": "did:plc:recipient456", 18 18 "wrappedKey": {{ 19 19 "did": "did:plc:recipient456", ··· 37 37 "commit": {{ 38 38 "rev": "3l3qo2vutsw2b", 39 39 "operation": "{operation}", 40 - "collection": "app.opake.cloud.keyring", 40 + "collection": "app.opake.keyring", 41 41 "rkey": "3def", 42 42 "record": {{ 43 - "version": 1, 43 + "opakeVersion": 1, 44 44 "name": "family-photos", 45 45 "algo": "aes-256-gcm", 46 46 "members": [ ··· 94 94 document_uri, 95 95 .. 96 96 } => { 97 - assert_eq!(uri, "at://did:plc:owner123/app.opake.cloud.grant/3abc"); 97 + assert_eq!(uri, "at://did:plc:owner123/app.opake.grant/3abc"); 98 98 assert_eq!(owner_did, "did:plc:owner123"); 99 99 assert_eq!(recipient_did, "did:plc:recipient456"); 100 100 assert_eq!( 101 101 document_uri, 102 - "at://did:plc:owner123/app.opake.cloud.document/3xyz" 102 + "at://did:plc:owner123/app.opake.document/3xyz" 103 103 ); 104 104 } 105 105 other => panic!("expected UpsertGrant, got {other:?}"), ··· 115 115 116 116 #[test] 117 117 fn parses_grant_delete() { 118 - let json = delete_event_json("app.opake.cloud.grant", "3abc"); 118 + let json = delete_event_json("app.opake.grant", "3abc"); 119 119 let (event, _) = parse_event(&json).unwrap(); 120 120 match event { 121 121 IndexableEvent::DeleteGrant { uri } => { 122 - assert_eq!(uri, "at://did:plc:owner123/app.opake.cloud.grant/3abc"); 122 + assert_eq!(uri, "at://did:plc:owner123/app.opake.grant/3abc"); 123 123 } 124 124 other => panic!("expected DeleteGrant, got {other:?}"), 125 125 } ··· 138 138 name, 139 139 member_dids, 140 140 } => { 141 - assert_eq!(uri, "at://did:plc:owner123/app.opake.cloud.keyring/3def"); 141 + assert_eq!(uri, "at://did:plc:owner123/app.opake.keyring/3def"); 142 142 assert_eq!(owner_did, "did:plc:owner123"); 143 143 assert_eq!(name, "family-photos"); 144 144 assert_eq!(member_dids, vec!["did:plc:alice", "did:plc:bob"]); ··· 149 149 150 150 #[test] 151 151 fn parses_keyring_delete() { 152 - let json = delete_event_json("app.opake.cloud.keyring", "3def"); 152 + let json = delete_event_json("app.opake.keyring", "3def"); 153 153 let (event, _) = parse_event(&json).unwrap(); 154 154 assert!(matches!(event, IndexableEvent::DeleteKeyring { .. })); 155 155 } ··· 193 193 "commit": { 194 194 "rev": "abc", 195 195 "operation": "create", 196 - "collection": "app.opake.cloud.grant", 196 + "collection": "app.opake.grant", 197 197 "rkey": "3abc", 198 198 "record": {"garbage": true}, 199 199 "cid": "bafyabc"
+2 -3
crates/opake-appview/src/firehose/subscribe.rs
··· 10 10 11 11 /// Build the Jetstream subscription URL with collection filters and optional cursor. 12 12 pub fn subscription_url(base_url: &str, cursor: Option<i64>) -> String { 13 - let mut url = format!( 14 - "{base_url}?wantedCollections=app.opake.cloud.grant&wantedCollections=app.opake.cloud.keyring" 15 - ); 13 + let mut url = 14 + format!("{base_url}?wantedCollections=app.opake.grant&wantedCollections=app.opake.keyring"); 16 15 if let Some(cursor_us) = cursor { 17 16 url.push_str(&format!("&cursor={cursor_us}")); 18 17 }
+7 -7
crates/opake-appview/src/indexer_tests.rs
··· 14 14 fn indexes_grant_create() { 15 15 let state = test_state(); 16 16 let event = IndexableEvent::UpsertGrant { 17 - uri: "at://did:plc:owner/app.opake.cloud.grant/3abc".into(), 17 + uri: "at://did:plc:owner/app.opake.grant/3abc".into(), 18 18 owner_did: "did:plc:owner".into(), 19 19 recipient_did: "did:plc:recipient".into(), 20 - document_uri: "at://did:plc:owner/app.opake.cloud.document/3xyz".into(), 20 + document_uri: "at://did:plc:owner/app.opake.document/3xyz".into(), 21 21 permissions: Some("read".into()), 22 22 note: Some("shared file".into()), 23 23 created_at: "2026-03-01T12:00:00Z".into(), ··· 36 36 #[test] 37 37 fn indexes_grant_delete() { 38 38 let state = test_state(); 39 - let uri = "at://did:plc:owner/app.opake.cloud.grant/3abc"; 39 + let uri = "at://did:plc:owner/app.opake.grant/3abc"; 40 40 41 41 let create = IndexableEvent::UpsertGrant { 42 42 uri: uri.into(), 43 43 owner_did: "did:plc:owner".into(), 44 44 recipient_did: "did:plc:recipient".into(), 45 - document_uri: "at://did:plc:owner/app.opake.cloud.document/3xyz".into(), 45 + document_uri: "at://did:plc:owner/app.opake.document/3xyz".into(), 46 46 permissions: None, 47 47 note: None, 48 48 created_at: "2026-03-01T12:00:00Z".into(), ··· 63 63 fn indexes_keyring_create() { 64 64 let state = test_state(); 65 65 let event = IndexableEvent::UpsertKeyring { 66 - uri: "at://did:plc:owner/app.opake.cloud.keyring/3def".into(), 66 + uri: "at://did:plc:owner/app.opake.keyring/3def".into(), 67 67 owner_did: "did:plc:owner".into(), 68 68 name: "family-photos".into(), 69 69 member_dids: vec!["did:plc:alice".into(), "did:plc:bob".into()], ··· 87 87 #[test] 88 88 fn indexes_keyring_update_replaces_members() { 89 89 let state = test_state(); 90 - let uri = "at://did:plc:owner/app.opake.cloud.keyring/3def"; 90 + let uri = "at://did:plc:owner/app.opake.keyring/3def"; 91 91 92 92 let create = IndexableEvent::UpsertKeyring { 93 93 uri: uri.into(), ··· 122 122 #[test] 123 123 fn indexes_keyring_delete() { 124 124 let state = test_state(); 125 - let uri = "at://did:plc:owner/app.opake.cloud.keyring/3def"; 125 + let uri = "at://did:plc:owner/app.opake.keyring/3def"; 126 126 127 127 let create = IndexableEvent::UpsertKeyring { 128 128 uri: uri.into(),
+2 -2
crates/opake-cli/src/commands/inbox.rs
··· 85 85 86 86 fn grant(owner: &str, doc_suffix: &str, perms: Option<&str>, note: Option<&str>) -> InboxGrant { 87 87 InboxGrant { 88 - uri: "at://did:plc:owner/app.opake.cloud.grant/g1".into(), 88 + uri: "at://did:plc:owner/app.opake.grant/g1".into(), 89 89 owner_did: owner.into(), 90 - document_uri: format!("at://did:plc:owner/app.opake.cloud.document/{doc_suffix}"), 90 + document_uri: format!("at://did:plc:owner/app.opake.document/{doc_suffix}"), 91 91 permissions: perms.map(|s| s.into()), 92 92 note: note.map(|s| s.into()), 93 93 created_at: "2026-03-01T12:00:00Z".into(),
+3 -3
crates/opake-cli/src/commands/shared.rs
··· 72 72 73 73 fn entry(recipient: &str, doc: &str, perms: Option<&str>, note: Option<&str>) -> GrantEntry { 74 74 GrantEntry { 75 - uri: format!("at://did:plc:owner/app.opake.cloud.grant/g1"), 75 + uri: format!("at://did:plc:owner/app.opake.grant/g1"), 76 76 document: doc.into(), 77 77 recipient: recipient.into(), 78 78 permissions: perms.map(|s| s.into()), ··· 85 85 fn short_format() { 86 86 let entries = vec![entry( 87 87 "did:plc:bob", 88 - "at://did:plc:owner/app.opake.cloud.document/doc1", 88 + "at://did:plc:owner/app.opake.document/doc1", 89 89 Some("read"), 90 90 None, 91 91 )]; ··· 106 106 fn long_format_with_note() { 107 107 let entries = vec![entry( 108 108 "did:plc:bob", 109 - "at://did:plc:owner/app.opake.cloud.document/doc1", 109 + "at://did:plc:owner/app.opake.document/doc1", 110 110 Some("read"), 111 111 Some("tax doc"), 112 112 )];
+6 -7
crates/opake-core/src/atproto.rs
··· 1 1 // AT Protocol primitives: URIs, binary data wrappers, blob references. 2 2 // 3 3 // These types mirror atproto's JSON serialization conventions and are used 4 - // across both the XRPC client and the app.opake.cloud.* lexicon records. 4 + // across both the XRPC client and the app.opake.* lexicon records. 5 5 6 6 use serde::{Deserialize, Serialize}; 7 7 ··· 14 14 /// Parsed components of an `at://` URI. 15 15 /// 16 16 /// Format: `at://<authority>/<collection>/<rkey>` 17 - /// Example: `at://did:plc:abc123/app.opake.cloud.document/3abc` 17 + /// Example: `at://did:plc:abc123/app.opake.document/3abc` 18 18 #[derive(Debug, Clone, PartialEq, Eq)] 19 19 pub struct AtUri { 20 20 pub authority: String, ··· 109 109 110 110 #[test] 111 111 fn parse_valid_document_uri() { 112 - let uri = 113 - parse_at_uri("at://did:plc:abc123/app.opake.cloud.document/3jui2v6cv2a2w").unwrap(); 112 + let uri = parse_at_uri("at://did:plc:abc123/app.opake.document/3jui2v6cv2a2w").unwrap(); 114 113 assert_eq!(uri.authority, "did:plc:abc123"); 115 - assert_eq!(uri.collection, "app.opake.cloud.document"); 114 + assert_eq!(uri.collection, "app.opake.document"); 116 115 assert_eq!(uri.rkey, "3jui2v6cv2a2w"); 117 116 } 118 117 119 118 #[test] 120 119 fn parse_valid_grant_uri() { 121 - let uri = parse_at_uri("at://did:web:example.com/app.opake.cloud.grant/tid123").unwrap(); 120 + let uri = parse_at_uri("at://did:web:example.com/app.opake.grant/tid123").unwrap(); 122 121 assert_eq!(uri.authority, "did:web:example.com"); 123 - assert_eq!(uri.collection, "app.opake.cloud.grant"); 122 + assert_eq!(uri.collection, "app.opake.grant"); 124 123 assert_eq!(uri.rkey, "tid123"); 125 124 } 126 125
+1 -1
crates/opake-core/src/client/appview.rs
··· 125 125 126 126 fn grant_json(uri_suffix: &str) -> String { 127 127 format!( 128 - r#"{{"uri":"at://did:plc:owner/app.opake.cloud.grant/{uri_suffix}","ownerDid":"did:plc:owner","documentUri":"at://did:plc:owner/app.opake.cloud.document/doc1","permissions":"read","note":null,"createdAt":"2026-03-01T12:00:00Z"}}"# 128 + r#"{{"uri":"at://did:plc:owner/app.opake.grant/{uri_suffix}","ownerDid":"did:plc:owner","documentUri":"at://did:plc:owner/app.opake.document/doc1","permissions":"read","note":null,"createdAt":"2026-03-01T12:00:00Z"}}"# 129 129 ) 130 130 } 131 131
+5 -5
crates/opake-core/src/client/appview_types.rs
··· 30 30 fn deserialize_full_response() { 31 31 let json = r#"{ 32 32 "grants": [{ 33 - "uri": "at://did:plc:owner/app.opake.cloud.grant/tid1", 33 + "uri": "at://did:plc:owner/app.opake.grant/tid1", 34 34 "ownerDid": "did:plc:owner", 35 - "documentUri": "at://did:plc:owner/app.opake.cloud.document/doc1", 35 + "documentUri": "at://did:plc:owner/app.opake.document/doc1", 36 36 "permissions": "read", 37 37 "note": "tax docs", 38 38 "createdAt": "2026-03-01T12:00:00Z" ··· 45 45 assert_eq!(resp.grants[0].owner_did, "did:plc:owner"); 46 46 assert_eq!( 47 47 resp.grants[0].document_uri, 48 - "at://did:plc:owner/app.opake.cloud.document/doc1" 48 + "at://did:plc:owner/app.opake.document/doc1" 49 49 ); 50 50 assert_eq!(resp.grants[0].permissions.as_deref(), Some("read")); 51 51 assert_eq!(resp.grants[0].note.as_deref(), Some("tax docs")); ··· 64 64 fn deserialize_null_optionals() { 65 65 let json = r#"{ 66 66 "grants": [{ 67 - "uri": "at://did:plc:owner/app.opake.cloud.grant/tid1", 67 + "uri": "at://did:plc:owner/app.opake.grant/tid1", 68 68 "ownerDid": "did:plc:owner", 69 - "documentUri": "at://did:plc:owner/app.opake.cloud.document/doc1", 69 + "documentUri": "at://did:plc:owner/app.opake.document/doc1", 70 70 "permissions": null, 71 71 "note": null, 72 72 "createdAt": "2026-03-01T12:00:00Z"
+3 -3
crates/opake-core/src/client/list.rs
··· 43 43 } 44 44 }; 45 45 46 - if records::check_version(parsed.version()).is_err() { 46 + if records::check_version(parsed.opake_version()).is_err() { 47 47 debug!( 48 48 "skipping record {} with unsupported version {}", 49 49 record.uri, 50 - parsed.version() 50 + parsed.opake_version() 51 51 ); 52 52 continue; 53 53 } ··· 83 83 } 84 84 85 85 impl Versioned for FakeRecord { 86 - fn version(&self) -> u32 { 86 + fn opake_version(&self) -> u32 { 87 87 self.version 88 88 } 89 89 }
+1 -1
crates/opake-core/src/client/xrpc/repo.rs
··· 45 45 /// Upsert a record with an explicit rkey via `com.atproto.repo.putRecord`. 46 46 /// 47 47 /// Idempotent — creates or overwrites the record at `collection/rkey`. 48 - /// Used for singleton records like `app.opake.cloud.publicKey/self`. 48 + /// Used for singleton records like `app.opake.publicKey/self`. 49 49 pub async fn put_record<R: Serialize>( 50 50 &mut self, 51 51 collection: &str,
+7 -10
crates/opake-core/src/client/xrpc/xrpc_tests.rs
··· 185 185 186 186 let mut client = mock_client(mock.clone()); 187 187 let page = client 188 - .list_records("app.opake.cloud.document", Some(100), None) 188 + .list_records("app.opake.document", Some(100), None) 189 189 .await 190 190 .unwrap(); 191 191 ··· 230 230 231 231 let mut client = mock_client(mock); 232 232 let err = client 233 - .list_records("app.opake.cloud.document", Some(100), None) 233 + .list_records("app.opake.document", Some(100), None) 234 234 .await 235 235 .unwrap_err(); 236 236 ··· 250 250 251 251 let mut client = mock_client(mock); 252 252 let err = client 253 - .list_records("app.opake.cloud.document", Some(100), None) 253 + .list_records("app.opake.document", Some(100), None) 254 254 .await 255 255 .unwrap_err(); 256 256 ··· 287 287 async fn put_record_sends_rkey_and_returns_ref() { 288 288 let mock = MockTransport::new(); 289 289 let body = serde_json::json!({ 290 - "uri": "at://did:plc:test/app.opake.cloud.publicKey/self", 290 + "uri": "at://did:plc:test/app.opake.publicKey/self", 291 291 "cid": "bafyputrecord", 292 292 }); 293 293 mock.enqueue(success_response(&body.to_string())); ··· 296 296 297 297 let record = serde_json::json!({ "hello": "world" }); 298 298 let result = client 299 - .put_record("app.opake.cloud.publicKey", "self", &record) 299 + .put_record("app.opake.publicKey", "self", &record) 300 300 .await 301 301 .unwrap(); 302 302 303 - assert_eq!( 304 - result.uri, 305 - "at://did:plc:test/app.opake.cloud.publicKey/self" 306 - ); 303 + assert_eq!(result.uri, "at://did:plc:test/app.opake.publicKey/self"); 307 304 assert_eq!(result.cid, "bafyputrecord"); 308 305 309 306 let reqs = mock.requests(); ··· 316 313 _ => panic!("expected JSON body"), 317 314 }; 318 315 assert_eq!(sent_body["rkey"], "self"); 319 - assert_eq!(sent_body["collection"], "app.opake.cloud.publicKey"); 316 + assert_eq!(sent_body["collection"], "app.opake.publicKey"); 320 317 assert_eq!(sent_body["repo"], "did:plc:test"); 321 318 }
+2 -2
crates/opake-core/src/directories/create.rs
··· 33 33 34 34 #[tokio::test] 35 35 async fn happy_path() { 36 - let uri = format!("at://{TEST_DID}/app.opake.cloud.directory/tid123"); 36 + let uri = format!("at://{TEST_DID}/app.opake.directory/tid123"); 37 37 let mock = MockTransport::new(); 38 38 mock.enqueue(create_record_response(&uri)); 39 39 ··· 50 50 51 51 match &reqs[0].body { 52 52 Some(RequestBody::Json(v)) => { 53 - assert_eq!(v["collection"], "app.opake.cloud.directory"); 53 + assert_eq!(v["collection"], "app.opake.directory"); 54 54 let record: Directory = serde_json::from_value(v["record"].clone()).unwrap(); 55 55 assert_eq!(record.name, "Photos"); 56 56 assert!(record.entries.is_empty());
+8 -8
crates/opake-core/src/directories/delete.rs
··· 10 10 /// Delete a directory record by AT-URI. 11 11 /// 12 12 /// Rejects deletion of the root directory (rkey "self") and non-empty 13 - /// directories. Validates the collection is `app.opake.cloud.directory`. 13 + /// directories. Validates the collection is `app.opake.directory`. 14 14 pub async fn delete_directory( 15 15 client: &mut XrpcClient<impl Transport>, 16 16 uri: &str, ··· 36 36 .await?; 37 37 38 38 let directory: Directory = serde_json::from_value(entry.value)?; 39 - records::check_version(directory.version)?; 39 + records::check_version(directory.opake_version)?; 40 40 41 41 if !directory.entries.is_empty() { 42 42 return Err(Error::InvalidRecord(format!( ··· 66 66 #[tokio::test] 67 67 async fn happy_path() { 68 68 let directory = dummy_directory("Photos"); 69 - let uri = format!("at://{TEST_DID}/app.opake.cloud.directory/dir1"); 69 + let uri = format!("at://{TEST_DID}/app.opake.directory/dir1"); 70 70 let mock = MockTransport::new(); 71 71 mock.enqueue(get_record_response(&uri, &directory)); 72 72 mock.enqueue(HttpResponse { ··· 88 88 async fn rejects_root_deletion() { 89 89 let mock = MockTransport::new(); 90 90 let mut client = mock_client(mock); 91 - let uri = format!("at://{TEST_DID}/app.opake.cloud.directory/self"); 91 + let uri = format!("at://{TEST_DID}/app.opake.directory/self"); 92 92 93 93 let err = delete_directory(&mut client, &uri).await.unwrap_err(); 94 94 assert!(err.to_string().contains("root directory")); ··· 98 98 async fn rejects_non_empty_directory() { 99 99 let directory = dummy_directory_with_entries( 100 100 "Photos", 101 - vec!["at://did:plc:test/app.opake.cloud.document/doc1".into()], 101 + vec!["at://did:plc:test/app.opake.document/doc1".into()], 102 102 ); 103 - let uri = format!("at://{TEST_DID}/app.opake.cloud.directory/dir1"); 103 + let uri = format!("at://{TEST_DID}/app.opake.directory/dir1"); 104 104 let mock = MockTransport::new(); 105 105 mock.enqueue(get_record_response(&uri, &directory)); 106 106 ··· 113 113 async fn rejects_wrong_collection() { 114 114 let mock = MockTransport::new(); 115 115 let mut client = mock_client(mock); 116 - let uri = format!("at://{TEST_DID}/app.opake.cloud.document/abc"); 116 + let uri = format!("at://{TEST_DID}/app.opake.document/abc"); 117 117 118 118 let err = delete_directory(&mut client, &uri).await.unwrap_err(); 119 119 assert!(err.to_string().contains("expected a directory URI")); ··· 132 132 133 133 #[tokio::test] 134 134 async fn pds_404_on_fetch() { 135 - let uri = format!("at://{TEST_DID}/app.opake.cloud.directory/gone"); 135 + let uri = format!("at://{TEST_DID}/app.opake.directory/gone"); 136 136 let mock = MockTransport::new(); 137 137 mock.enqueue(HttpResponse { 138 138 status: 404,
+8 -8
crates/opake-core/src/directories/entries.rs
··· 24 24 .await?; 25 25 26 26 let mut directory: Directory = serde_json::from_value(entry.value)?; 27 - records::check_version(directory.version)?; 27 + records::check_version(directory.opake_version)?; 28 28 29 29 if directory.entries.iter().any(|e| e == entry_uri) { 30 30 return Err(Error::InvalidRecord(format!( ··· 60 60 .await?; 61 61 62 62 let mut directory: Directory = serde_json::from_value(entry.value)?; 63 - records::check_version(directory.version)?; 63 + records::check_version(directory.opake_version)?; 64 64 65 65 let original_len = directory.entries.len(); 66 66 directory.entries.retain(|e| e != entry_uri); ··· 92 92 dummy_directory_with_entries, get_record_response, mock_client, put_record_response, 93 93 }; 94 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"; 95 + const DIR_URI: &str = "at://did:plc:test/app.opake.directory/dir1"; 96 + const DOC_URI: &str = "at://did:plc:test/app.opake.document/doc1"; 97 + const DOC_URI_2: &str = "at://did:plc:test/app.opake.document/doc2"; 98 98 99 99 #[tokio::test] 100 100 async fn add_entry_happy_path() { ··· 140 140 #[tokio::test] 141 141 async fn add_entry_rejects_future_version() { 142 142 let mut directory = dummy_directory_with_entries("/", vec![]); 143 - directory.version = SCHEMA_VERSION + 1; 143 + directory.opake_version = SCHEMA_VERSION + 1; 144 144 145 145 let mock = MockTransport::new(); 146 146 mock.enqueue(get_record_response(DIR_URI, &directory)); ··· 186 186 let err = remove_entry( 187 187 &mut client, 188 188 DIR_URI, 189 - "at://did:plc:test/app.opake.cloud.document/nope", 189 + "at://did:plc:test/app.opake.document/nope", 190 190 "2026-03-01T12:00:00Z", 191 191 ) 192 192 .await ··· 198 198 #[tokio::test] 199 199 async fn remove_entry_rejects_future_version() { 200 200 let mut directory = dummy_directory_with_entries("/", vec![DOC_URI.into()]); 201 - directory.version = SCHEMA_VERSION + 1; 201 + directory.opake_version = SCHEMA_VERSION + 1; 202 202 203 203 let mock = MockTransport::new(); 204 204 mock.enqueue(get_record_response(DIR_URI, &directory));
+1 -1
crates/opake-core/src/directories/get_or_create_root.rs
··· 48 48 TEST_DID, 49 49 }; 50 50 51 - const ROOT_URI: &str = "at://did:plc:test/app.opake.cloud.directory/self"; 51 + const ROOT_URI: &str = "at://did:plc:test/app.opake.directory/self"; 52 52 53 53 #[tokio::test] 54 54 async fn returns_existing_root() {
+3 -3
crates/opake-core/src/directories/list.rs
··· 58 58 assert!(entries[0].uri.contains("dir1")); 59 59 60 60 let reqs = mock.requests(); 61 - assert!(reqs[0].url.contains("app.opake.cloud.directory")); 61 + assert!(reqs[0].url.contains("app.opake.directory")); 62 62 } 63 63 64 64 #[tokio::test] ··· 69 69 "dir2", 70 70 dummy_directory_with_entries( 71 71 "Documents", 72 - vec!["at://did:plc:test/app.opake.cloud.document/a".into()], 72 + vec!["at://did:plc:test/app.opake.document/a".into()], 73 73 ), 74 74 ), 75 75 ]; ··· 122 122 #[tokio::test] 123 123 async fn skips_future_version() { 124 124 let mut directory = dummy_directory("Future"); 125 - directory.version = records::SCHEMA_VERSION + 1; 125 + directory.opake_version = records::SCHEMA_VERSION + 1; 126 126 127 127 let mock = MockTransport::new(); 128 128 mock.enqueue(list_records_response(&[("d1", directory)], None));
+1 -1
crates/opake-core/src/directories/mod.rs
··· 22 22 pub use remove::{remove, RemoveResult}; 23 23 pub use tree::{DirectoryTree, EntryKind, ResolvedPath}; 24 24 25 - pub const DIRECTORY_COLLECTION: &str = "app.opake.cloud.directory"; 25 + pub const DIRECTORY_COLLECTION: &str = "app.opake.directory"; 26 26 pub const ROOT_DIRECTORY_RKEY: &str = "self"; 27 27 pub const ROOT_DIRECTORY_NAME: &str = "/"; 28 28
+2 -2
crates/opake-core/src/directories/mv.rs
··· 133 133 .get_record(&at_uri.authority, &at_uri.collection, &at_uri.rkey) 134 134 .await?; 135 135 let mut doc: Document = serde_json::from_value(entry.value)?; 136 - records::check_version(doc.version)?; 136 + records::check_version(doc.opake_version)?; 137 137 138 138 doc.name = new_name.to_string(); 139 139 doc.modified_at = Some(modified_at.to_string()); ··· 153 153 .get_record(&at_uri.authority, &at_uri.collection, &at_uri.rkey) 154 154 .await?; 155 155 let mut dir: Directory = serde_json::from_value(entry.value)?; 156 - records::check_version(dir.version)?; 156 + records::check_version(dir.opake_version)?; 157 157 158 158 dir.name = new_name.to_string(); 159 159 dir.modified_at = Some(modified_at.to_string());
+4 -4
crates/opake-core/src/directories/mv_tests.rs
··· 22 22 } 23 23 } 24 24 25 - const ROOT_URI: &str = "at://did:plc:test/app.opake.cloud.directory/self"; 26 - const DIR_A_URI: &str = "at://did:plc:test/app.opake.cloud.directory/dirA"; 27 - const DIR_B_URI: &str = "at://did:plc:test/app.opake.cloud.directory/dirB"; 28 - const DOC_URI: &str = "at://did:plc:test/app.opake.cloud.document/doc1"; 25 + const ROOT_URI: &str = "at://did:plc:test/app.opake.directory/self"; 26 + const DIR_A_URI: &str = "at://did:plc:test/app.opake.directory/dirA"; 27 + const DIR_B_URI: &str = "at://did:plc:test/app.opake.directory/dirB"; 28 + const DOC_URI: &str = "at://did:plc:test/app.opake.document/doc1"; 29 29 30 30 async fn load_tree_with(dirs: &[(&str, Directory)]) -> DirectoryTree { 31 31 let mock = MockTransport::new();
+8 -8
crates/opake-core/src/directories/remove_tests.rs
··· 7 7 put_record_response, 8 8 }; 9 9 10 - const ROOT_URI: &str = "at://did:plc:test/app.opake.cloud.directory/self"; 11 - const DIR_PHOTOS_URI: &str = "at://did:plc:test/app.opake.cloud.directory/photos"; 12 - const DIR_VACATION_URI: &str = "at://did:plc:test/app.opake.cloud.directory/vacation"; 13 - const DOC_BEACH_URI: &str = "at://did:plc:test/app.opake.cloud.document/beach"; 14 - const DOC_NOTES_URI: &str = "at://did:plc:test/app.opake.cloud.document/notes"; 15 - const DOC_SUNSET_URI: &str = "at://did:plc:test/app.opake.cloud.document/sunset"; 10 + const ROOT_URI: &str = "at://did:plc:test/app.opake.directory/self"; 11 + const DIR_PHOTOS_URI: &str = "at://did:plc:test/app.opake.directory/photos"; 12 + const DIR_VACATION_URI: &str = "at://did:plc:test/app.opake.directory/vacation"; 13 + const DOC_BEACH_URI: &str = "at://did:plc:test/app.opake.document/beach"; 14 + const DOC_NOTES_URI: &str = "at://did:plc:test/app.opake.document/notes"; 15 + const DOC_SUNSET_URI: &str = "at://did:plc:test/app.opake.document/sunset"; 16 16 17 17 fn delete_ok() -> HttpResponse { 18 18 HttpResponse { ··· 129 129 let tree = DirectoryTree::load(&mut client).await.unwrap(); 130 130 131 131 let resolved = ResolvedPath { 132 - uri: "at://did:plc:test/app.opake.cloud.document/orphan".into(), 132 + uri: "at://did:plc:test/app.opake.document/orphan".into(), 133 133 kind: EntryKind::Document, 134 134 name: "orphan.txt".into(), 135 135 parent_uri: None, ··· 153 153 let mock = MockTransport::new(); 154 154 let root = dummy_directory_with_entries( 155 155 "/", 156 - vec!["at://did:plc:test/app.opake.cloud.directory/empty".into()], 156 + vec!["at://did:plc:test/app.opake.directory/empty".into()], 157 157 ); 158 158 mock.enqueue(list_records_response( 159 159 &[
+1 -1
crates/opake-core/src/directories/tree.rs
··· 83 83 Err(_) => return Ok(None), 84 84 }; 85 85 86 - if records::check_version(doc.version).is_err() { 86 + if records::check_version(doc.opake_version).is_err() { 87 87 return Ok(None); 88 88 } 89 89
+10 -16
crates/opake-core/src/directories/tree_tests.rs
··· 4 4 5 5 use super::super::tests::{dummy_directory_with_entries, list_records_response, mock_client}; 6 6 7 - const ROOT_URI: &str = "at://did:plc:test/app.opake.cloud.directory/self"; 8 - const DIR_PHOTOS_URI: &str = "at://did:plc:test/app.opake.cloud.directory/photos"; 9 - const DIR_VACATION_URI: &str = "at://did:plc:test/app.opake.cloud.directory/vacation"; 10 - const DOC_BEACH_URI: &str = "at://did:plc:test/app.opake.cloud.document/beach"; 11 - const DOC_NOTES_URI: &str = "at://did:plc:test/app.opake.cloud.document/notes"; 12 - const DOC_SUNSET_URI: &str = "at://did:plc:test/app.opake.cloud.document/sunset"; 7 + const ROOT_URI: &str = "at://did:plc:test/app.opake.directory/self"; 8 + const DIR_PHOTOS_URI: &str = "at://did:plc:test/app.opake.directory/photos"; 9 + const DIR_VACATION_URI: &str = "at://did:plc:test/app.opake.directory/vacation"; 10 + const DOC_BEACH_URI: &str = "at://did:plc:test/app.opake.document/beach"; 11 + const DOC_NOTES_URI: &str = "at://did:plc:test/app.opake.document/notes"; 12 + const DOC_SUNSET_URI: &str = "at://did:plc:test/app.opake.document/sunset"; 13 13 14 14 /// getRecord response for a document — minimal but parseable. 15 15 fn doc_record_response(uri: &str, name: &str) -> HttpResponse { ··· 163 163 164 164 let mut client = mock_client(mock); 165 165 let err = tree 166 - .resolve( 167 - &mut client, 168 - "at://did:plc:test/app.opake.cloud.document/nope", 169 - ) 166 + .resolve(&mut client, "at://did:plc:test/app.opake.document/nope") 170 167 .await 171 168 .unwrap_err(); 172 169 assert!(matches!(err, Error::NotFound(_))); ··· 327 324 328 325 let mut client = mock_client(mock); 329 326 let resolved = tree.resolve(&mut client, "Photos").await.unwrap(); 330 - assert_eq!( 331 - resolved.uri, 332 - "at://did:plc:test/app.opake.cloud.directory/photos" 333 - ); 327 + assert_eq!(resolved.uri, "at://did:plc:test/app.opake.directory/photos"); 334 328 assert_eq!(resolved.kind, EntryKind::Directory); 335 329 } 336 330 ··· 390 384 "self", 391 385 dummy_directory_with_entries( 392 386 "/", 393 - vec!["at://did:plc:test/app.opake.cloud.directory/empty".into()], 387 + vec!["at://did:plc:test/app.opake.directory/empty".into()], 394 388 ), 395 389 ), 396 390 ("empty", dummy_directory_with_entries("Empty", vec![])), ··· 401 395 let mut client = mock_client(mock); 402 396 let tree = DirectoryTree::load(&mut client).await.unwrap(); 403 397 404 - let descendants = tree.collect_descendants("at://did:plc:test/app.opake.cloud.directory/empty"); 398 + let descendants = tree.collect_descendants("at://did:plc:test/app.opake.directory/empty"); 405 399 assert!(descendants.is_empty()); 406 400 }
+6 -6
crates/opake-core/src/documents/delete.rs
··· 7 7 use super::DOCUMENT_COLLECTION; 8 8 9 9 /// Delete a document record by AT-URI. Validates the collection is 10 - /// `app.opake.cloud.document` to prevent accidental deletion of other 10 + /// `app.opake.document` to prevent accidental deletion of other 11 11 /// record types. The blob becomes orphaned and will eventually be 12 12 /// garbage-collected by the PDS. 13 13 pub async fn delete_document( ··· 49 49 }); 50 50 51 51 let mut client = mock_client(mock.clone()); 52 - let uri = format!("at://{}/app.opake.cloud.document/abc123", TEST_DID); 52 + let uri = format!("at://{}/app.opake.document/abc123", TEST_DID); 53 53 delete_document(&mut client, &uri).await.unwrap(); 54 54 55 55 let requests = mock.requests(); ··· 58 58 59 59 match &requests[0].body { 60 60 Some(RequestBody::Json(v)) => { 61 - assert_eq!(v["collection"], "app.opake.cloud.document"); 61 + assert_eq!(v["collection"], "app.opake.document"); 62 62 assert_eq!(v["rkey"], "abc123"); 63 63 assert_eq!(v["repo"], TEST_DID); 64 64 } ··· 70 70 async fn rejects_grant_uri() { 71 71 let mock = MockTransport::new(); 72 72 let mut client = mock_client(mock); 73 - let uri = format!("at://{}/app.opake.cloud.grant/abc123", TEST_DID); 73 + let uri = format!("at://{}/app.opake.grant/abc123", TEST_DID); 74 74 let err = delete_document(&mut client, &uri).await.unwrap_err(); 75 75 assert!( 76 76 err.to_string().contains("expected a document URI"), ··· 108 108 }); 109 109 110 110 let mut client = mock_client(mock); 111 - let uri = format!("at://{}/app.opake.cloud.document/gone", TEST_DID); 111 + let uri = format!("at://{}/app.opake.document/gone", TEST_DID); 112 112 let err = delete_document(&mut client, &uri).await.unwrap_err(); 113 113 assert!(matches!(err, Error::NotFound(_))); 114 114 } ··· 123 123 }); 124 124 125 125 let mut client = mock_client(mock); 126 - let uri = format!("at://{}/app.opake.cloud.document/abc", TEST_DID); 126 + let uri = format!("at://{}/app.opake.document/abc", TEST_DID); 127 127 let err = delete_document(&mut client, &uri).await.unwrap_err(); 128 128 assert!(matches!(err, Error::Xrpc { .. })); 129 129 }
+5 -5
crates/opake-core/src/documents/download.rs
··· 113 113 .await?; 114 114 115 115 let doc: Document = serde_json::from_value(entry.value)?; 116 - records::check_version(doc.version)?; 116 + records::check_version(doc.opake_version)?; 117 117 118 118 debug!("unwrapping content key"); 119 119 let content_key = unwrap_document_key(&doc, did, private_key, group_key)?; ··· 313 313 "uri": TEST_URI, 314 314 "cid": "bafyrecord", 315 315 "value": { 316 - "version": 1, 316 + "opakeVersion": 1, 317 317 "name": "keyring-doc.txt", 318 318 "blob": { 319 319 "$type": "blob", ··· 322 322 "size": 100, 323 323 }, 324 324 "encryption": { 325 - "$type": "app.opake.cloud.document#keyringEncryption", 325 + "$type": "app.opake.document#keyringEncryption", 326 326 "keyringRef": { 327 - "keyring": "at://did:plc:test/app.opake.cloud.keyring/kr1", 327 + "keyring": "at://did:plc:test/app.opake.keyring/kr1", 328 328 "wrappedContentKey": { "$bytes": "AAAA" }, 329 329 "rotation": 1, 330 330 }, ··· 393 393 let (public_key, private_key) = test_keypair(); 394 394 let fixture = encrypt_for_download(b"data", &public_key); 395 395 let mut doc = document_from_fixture(&fixture); 396 - doc.version = records::SCHEMA_VERSION + 1; 396 + doc.opake_version = records::SCHEMA_VERSION + 1; 397 397 398 398 let mock = MockTransport::new(); 399 399 mock.enqueue(record_response(&doc));
+7 -11
crates/opake-core/src/documents/download_grant.rs
··· 49 49 .await?; 50 50 51 51 let grant: Grant = serde_json::from_value(grant_entry.value)?; 52 - records::check_version(grant.version)?; 52 + records::check_version(grant.opake_version)?; 53 53 54 54 // Unwrap the content key from the grant 55 55 debug!("unwrapping content key from grant"); ··· 68 68 .await?; 69 69 70 70 let doc: Document = serde_json::from_value(doc_entry.value)?; 71 - records::check_version(doc.version)?; 71 + records::check_version(doc.opake_version)?; 72 72 73 73 // Grants always wrap the content key directly — the document's own 74 74 // encryption type doesn't matter for the grant path, we just need the nonce. ··· 113 113 114 114 const OWNER_DID: &str = "did:plc:owner"; 115 115 const OWNER_PDS: &str = "https://pds.owner.example.com"; 116 - const GRANT_URI: &str = "at://did:plc:owner/app.opake.cloud.grant/grant1"; 117 - const DOC_URI: &str = "at://did:plc:owner/app.opake.cloud.document/doc1"; 116 + const GRANT_URI: &str = "at://did:plc:owner/app.opake.grant/grant1"; 117 + const DOC_URI: &str = "at://did:plc:owner/app.opake.document/doc1"; 118 118 119 119 fn did_document_response() -> HttpResponse { 120 120 let body = serde_json::json!({ ··· 272 272 #[tokio::test] 273 273 async fn rejects_non_grant_uri() { 274 274 let mock = MockTransport::new(); 275 - let err = download_from_grant( 276 - &mock, 277 - &[0u8; 32], 278 - "at://did:plc:x/app.opake.cloud.document/abc", 279 - ) 280 - .await 281 - .unwrap_err(); 275 + let err = download_from_grant(&mock, &[0u8; 32], "at://did:plc:x/app.opake.document/abc") 276 + .await 277 + .unwrap_err(); 282 278 assert!(err.to_string().contains("grant"), "got: {err}"); 283 279 } 284 280 }
+2 -2
crates/opake-core/src/documents/download_keyring.rs
··· 68 68 .await?; 69 69 70 70 let doc: Document = serde_json::from_value(doc_entry.value)?; 71 - records::check_version(doc.version)?; 71 + records::check_version(doc.opake_version)?; 72 72 73 73 // Must be keyring-encrypted 74 74 let kr_enc = match &doc.encryption { ··· 95 95 .await?; 96 96 97 97 let keyring: Keyring = serde_json::from_value(kr_entry.value)?; 98 - records::check_version(keyring.version)?; 98 + records::check_version(keyring.opake_version)?; 99 99 100 100 // Find the member's wrapped group key — check the current rotation first, 101 101 // then fall back to key_history if the document was encrypted under an
+3 -3
crates/opake-core/src/documents/download_keyring_tests.rs
··· 9 9 const OWNER_PDS: &str = "https://pds.owner.example.com"; 10 10 const MEMBER_DID: &str = "did:plc:member"; 11 11 const KR_RKEY: &str = "kr1"; 12 - const DOC_URI: &str = "at://did:plc:owner/app.opake.cloud.document/doc1"; 13 - const KR_URI: &str = "at://did:plc:owner/app.opake.cloud.keyring/kr1"; 12 + const DOC_URI: &str = "at://did:plc:owner/app.opake.document/doc1"; 13 + const KR_URI: &str = "at://did:plc:owner/app.opake.keyring/kr1"; 14 14 15 15 fn did_document_response() -> HttpResponse { 16 16 let body = serde_json::json!({ ··· 188 188 &mock, 189 189 MEMBER_DID, 190 190 &[0u8; 32], 191 - "at://did:plc:x/app.opake.cloud.grant/abc", 191 + "at://did:plc:x/app.opake.grant/abc", 192 192 ) 193 193 .await 194 194 .unwrap_err();
+4 -4
crates/opake-core/src/documents/list.rs
··· 60 60 let requests = mock.requests(); 61 61 assert_eq!(requests.len(), 1); 62 62 assert!(requests[0].url.contains("listRecords")); 63 - assert!(requests[0].url.contains("app.opake.cloud.document")); 63 + assert!(requests[0].url.contains("app.opake.document")); 64 64 } 65 65 66 66 #[tokio::test] ··· 128 128 let body = serde_json::json!({ 129 129 "records": [ 130 130 { 131 - "uri": "at://did:plc:test/app.opake.cloud.document/bad1", 131 + "uri": "at://did:plc:test/app.opake.document/bad1", 132 132 "cid": "bafybad", 133 133 "value": { "this": "is not a document" }, 134 134 }, 135 135 { 136 - "uri": "at://did:plc:test/app.opake.cloud.document/good1", 136 + "uri": "at://did:plc:test/app.opake.document/good1", 137 137 "cid": "bafygood", 138 138 "value": dummy_document("good.txt", 42, vec![]), 139 139 }, ··· 157 157 #[tokio::test] 158 158 async fn skips_future_schema_version() { 159 159 let mut doc = dummy_document("future.txt", 100, vec![]); 160 - doc.version = records::SCHEMA_VERSION + 1; 160 + doc.opake_version = records::SCHEMA_VERSION + 1; 161 161 162 162 let mock = MockTransport::new(); 163 163 mock.enqueue(list_records_response(&[("f1", doc)], None));
+3 -3
crates/opake-core/src/documents/mod.rs
··· 23 23 encrypt_and_upload, encrypt_and_upload_keyring, KeyringUploadParams, UploadParams, 24 24 }; 25 25 26 - pub const DOCUMENT_COLLECTION: &str = "app.opake.cloud.document"; 26 + pub const DOCUMENT_COLLECTION: &str = "app.opake.document"; 27 27 28 28 #[cfg(test)] 29 29 pub(crate) mod tests { ··· 37 37 use crate::test_utils::MockTransport; 38 38 39 39 pub const TEST_DID: &str = "did:plc:test"; 40 - pub const TEST_URI: &str = "at://did:plc:test/app.opake.cloud.document/abc123"; 40 + pub const TEST_URI: &str = "at://did:plc:test/app.opake.document/abc123"; 41 41 42 42 pub fn mock_client(mock: MockTransport) -> XrpcClient<MockTransport> { 43 43 let session = Session::Legacy(LegacySession { ··· 90 90 .iter() 91 91 .map(|(rkey, doc)| { 92 92 serde_json::json!({ 93 - "uri": format!("at://{}/app.opake.cloud.document/{}", TEST_DID, rkey), 93 + "uri": format!("at://{}/app.opake.document/{}", TEST_DID, rkey), 94 94 "cid": "bafyrecord", 95 95 "value": doc, 96 96 })
+4 -7
crates/opake-core/src/documents/resolve.rs
··· 49 49 let mock = MockTransport::new(); 50 50 let mut client = mock_client(mock); 51 51 52 - let uri = resolve_uri( 53 - &mut client, 54 - "at://did:plc:test/app.opake.cloud.document/abc", 55 - ) 56 - .await 57 - .unwrap(); 58 - assert_eq!(uri, "at://did:plc:test/app.opake.cloud.document/abc"); 52 + let uri = resolve_uri(&mut client, "at://did:plc:test/app.opake.document/abc") 53 + .await 54 + .unwrap(); 55 + assert_eq!(uri, "at://did:plc:test/app.opake.document/abc"); 59 56 } 60 57 61 58 #[tokio::test]
+4 -4
crates/opake-core/src/documents/upload.rs
··· 206 206 /// Fake createRecord response — the PDS returns a record ref. 207 207 fn create_record_response() -> HttpResponse { 208 208 let body = serde_json::json!({ 209 - "uri": format!("at://{}/app.opake.cloud.document/new123", TEST_DID), 209 + "uri": format!("at://{}/app.opake.document/new123", TEST_DID), 210 210 "cid": "bafynewrecord", 211 211 }); 212 212 HttpResponse { ··· 251 251 .await 252 252 .unwrap(); 253 253 254 - assert!(uri.contains("app.opake.cloud.document")); 254 + assert!(uri.contains("app.opake.document")); 255 255 assert!(uri.contains(TEST_DID)); 256 256 257 257 let requests = mock.requests(); ··· 262 262 // Verify the document record sent to createRecord 263 263 match &requests[1].body { 264 264 Some(RequestBody::Json(v)) => { 265 - assert_eq!(v["collection"], "app.opake.cloud.document"); 265 + assert_eq!(v["collection"], "app.opake.document"); 266 266 let record = &v["record"]; 267 267 assert_eq!(record["name"], "hello.txt"); 268 268 assert_eq!(record["mimeType"], "text/plain"); ··· 309 309 .await 310 310 .unwrap(); 311 311 312 - assert!(uri.contains("app.opake.cloud.document")); 312 + assert!(uri.contains("app.opake.document")); 313 313 } 314 314 315 315 #[tokio::test]
+4 -4
crates/opake-core/src/keyrings/add_member.rs
··· 29 29 .await?; 30 30 31 31 let mut keyring: Keyring = serde_json::from_value(entry.value)?; 32 - records::check_version(keyring.version)?; 32 + records::check_version(keyring.opake_version)?; 33 33 34 34 if keyring.members.iter().any(|m| m.did == new_member_did) { 35 35 return Err(Error::InvalidRecord(format!( ··· 59 59 use crate::test_utils::MockTransport; 60 60 61 61 const TEST_DID: &str = "did:plc:owner"; 62 - const KEYRING_URI: &str = "at://did:plc:owner/app.opake.cloud.keyring/kr1"; 62 + const KEYRING_URI: &str = "at://did:plc:owner/app.opake.keyring/kr1"; 63 63 64 64 fn mock_client(mock: MockTransport) -> XrpcClient<MockTransport> { 65 65 let session = Session::Legacy(LegacySession { ··· 79 79 80 80 fn existing_keyring(owner_did: &str) -> Keyring { 81 81 Keyring { 82 - version: SCHEMA_VERSION, 82 + opake_version: SCHEMA_VERSION, 83 83 name: "test-keyring".into(), 84 84 description: None, 85 85 algo: "aes-256-gcm".into(), ··· 195 195 #[tokio::test] 196 196 async fn rejects_future_version() { 197 197 let mut keyring = existing_keyring(TEST_DID); 198 - keyring.version = SCHEMA_VERSION + 1; 198 + keyring.opake_version = SCHEMA_VERSION + 1; 199 199 let (pubkey, _) = test_keypair(); 200 200 let group_key = crypto::generate_content_key(&mut OsRng); 201 201
+1 -1
crates/opake-core/src/keyrings/create.rs
··· 82 82 async fn happy_path() { 83 83 let (pubkey, privkey) = test_pubkey(); 84 84 let mock = MockTransport::new(); 85 - let uri = format!("at://{TEST_DID}/app.opake.cloud.keyring/tid123"); 85 + let uri = format!("at://{TEST_DID}/app.opake.keyring/tid123"); 86 86 mock.enqueue(create_record_response(&uri)); 87 87 88 88 let mut client = mock_client(mock.clone());
+4 -4
crates/opake-core/src/keyrings/list.rs
··· 61 61 .collect(); 62 62 63 63 Keyring { 64 - version: records::SCHEMA_VERSION, 64 + opake_version: records::SCHEMA_VERSION, 65 65 name: name.into(), 66 66 description: None, 67 67 algo: "aes-256-gcm".into(), ··· 78 78 .iter() 79 79 .map(|(rkey, kr)| { 80 80 serde_json::json!({ 81 - "uri": format!("at://{TEST_DID}/app.opake.cloud.keyring/{rkey}"), 81 + "uri": format!("at://{TEST_DID}/app.opake.keyring/{rkey}"), 82 82 "cid": "bafykeyring", 83 83 "value": kr, 84 84 }) ··· 115 115 assert!(entries[0].uri.contains("kr1")); 116 116 117 117 let reqs = mock.requests(); 118 - assert!(reqs[0].url.contains("app.opake.cloud.keyring")); 118 + assert!(reqs[0].url.contains("app.opake.keyring")); 119 119 } 120 120 121 121 #[tokio::test] ··· 171 171 #[tokio::test] 172 172 async fn skips_future_version() { 173 173 let mut kr = dummy_keyring("future", 1); 174 - kr.version = records::SCHEMA_VERSION + 1; 174 + kr.opake_version = records::SCHEMA_VERSION + 1; 175 175 176 176 let mock = MockTransport::new(); 177 177 mock.enqueue(list_response(&[("kr1", kr)], None));
+1 -1
crates/opake-core/src/keyrings/mod.rs
··· 18 18 use crate::client::{Transport, XrpcClient}; 19 19 use crate::error::Error; 20 20 21 - pub const KEYRING_COLLECTION: &str = "app.opake.cloud.keyring"; 21 + pub const KEYRING_COLLECTION: &str = "app.opake.keyring"; 22 22 23 23 /// Resolve a keyring name to its AT-URI by listing all keyrings and matching. 24 24 ///
+1 -1
crates/opake-core/src/keyrings/remove_member.rs
··· 44 44 .await?; 45 45 46 46 let mut keyring: Keyring = serde_json::from_value(entry.value)?; 47 - records::check_version(keyring.version)?; 47 + records::check_version(keyring.opake_version)?; 48 48 49 49 let original_count = keyring.members.len(); 50 50 keyring.members.retain(|m| m.did != remove_did);
+1 -1
crates/opake-core/src/keyrings/remove_member_tests.rs
··· 5 5 use crate::test_utils::MockTransport; 6 6 7 7 const TEST_DID: &str = "did:plc:owner"; 8 - const KEYRING_URI: &str = "at://did:plc:owner/app.opake.cloud.keyring/kr1"; 8 + const KEYRING_URI: &str = "at://did:plc:owner/app.opake.keyring/kr1"; 9 9 10 10 fn mock_client(mock: MockTransport) -> XrpcClient<MockTransport> { 11 11 let session = Session::Legacy(LegacySession {
+1 -1
crates/opake-core/src/pairing/respond.rs
··· 29 29 let wrapped = wrap_key(&content_key, ephemeral_public_key, &identity.did, rng)?; 30 30 31 31 let record = PairResponse { 32 - version: SCHEMA_VERSION, 32 + opake_version: SCHEMA_VERSION, 33 33 request: request_uri.to_string(), 34 34 wrapped_key: wrapped, 35 35 ciphertext: AtBytes {
+2 -2
crates/opake-core/src/records/directory.rs
··· 6 6 #[serde(rename_all = "camelCase")] 7 7 pub struct Directory { 8 8 #[serde(default = "default_version")] 9 - pub version: u32, 9 + pub opake_version: u32, 10 10 pub name: String, 11 11 #[serde(default, skip_serializing_if = "Vec::is_empty")] 12 12 pub entries: Vec<String>, ··· 18 18 impl Directory { 19 19 pub fn new(name: String, created_at: String) -> Self { 20 20 Self { 21 - version: SCHEMA_VERSION, 21 + opake_version: SCHEMA_VERSION, 22 22 name, 23 23 entries: Vec::new(), 24 24 created_at,
+4 -4
crates/opake-core/src/records/document.rs
··· 22 22 #[derive(Debug, Clone, Serialize, Deserialize)] 23 23 #[serde(tag = "$type")] 24 24 pub enum Encryption { 25 - #[serde(rename = "app.opake.cloud.document#directEncryption")] 25 + #[serde(rename = "app.opake.document#directEncryption")] 26 26 Direct(DirectEncryption), 27 - #[serde(rename = "app.opake.cloud.document#keyringEncryption")] 27 + #[serde(rename = "app.opake.document#keyringEncryption")] 28 28 Keyring(KeyringEncryption), 29 29 } 30 30 ··· 32 32 #[serde(rename_all = "camelCase")] 33 33 pub struct Document { 34 34 #[serde(default = "default_version")] 35 - pub version: u32, 35 + pub opake_version: u32, 36 36 pub name: String, 37 37 #[serde(skip_serializing_if = "Option::is_none")] 38 38 pub mime_type: Option<String>, ··· 57 57 /// struct update syntax: `Document::new(..) { tags, ..Document::new(..) }` 58 58 pub fn new(name: String, blob: BlobRef, encryption: Encryption, created_at: String) -> Self { 59 59 Self { 60 - version: SCHEMA_VERSION, 60 + opake_version: SCHEMA_VERSION, 61 61 name, 62 62 mime_type: None, 63 63 size: None,
+2 -2
crates/opake-core/src/records/grant.rs
··· 6 6 #[serde(rename_all = "camelCase")] 7 7 pub struct Grant { 8 8 #[serde(default = "default_version")] 9 - pub version: u32, 9 + pub opake_version: u32, 10 10 pub document: String, 11 11 pub recipient: String, 12 12 pub wrapped_key: WrappedKey, ··· 27 27 created_at: String, 28 28 ) -> Self { 29 29 Self { 30 - version: SCHEMA_VERSION, 30 + opake_version: SCHEMA_VERSION, 31 31 document, 32 32 recipient, 33 33 wrapped_key,
+2 -2
crates/opake-core/src/records/keyring.rs
··· 14 14 #[serde(rename_all = "camelCase")] 15 15 pub struct Keyring { 16 16 #[serde(default = "default_version")] 17 - pub version: u32, 17 + pub opake_version: u32, 18 18 pub name: String, 19 19 #[serde(skip_serializing_if = "Option::is_none")] 20 20 pub description: Option<String>, ··· 34 34 /// Set description/modified_at via struct update syntax. 35 35 pub fn new(name: String, members: Vec<WrappedKey>, created_at: String) -> Self { 36 36 Self { 37 - version: SCHEMA_VERSION, 37 + opake_version: SCHEMA_VERSION, 38 38 name, 39 39 description: None, 40 40 algo: "aes-256-gcm".into(),
+10 -10
crates/opake-core/src/records/mod.rs
··· 1 - // Typed representations of the app.opake.cloud.* lexicon records. 1 + // Typed representations of the app.opake.* lexicon records. 2 2 // 3 3 // These mirror the lexicon JSON schemas and handle atproto's serialization 4 4 // conventions ($type discriminators, $bytes for binary data, $link for CIDs). ··· 31 31 pub use pair_response::{PairResponse, PAIR_RESPONSE_COLLECTION}; 32 32 pub use public_key::{PublicKeyRecord, PUBLIC_KEY_COLLECTION, PUBLIC_KEY_RKEY}; 33 33 34 - /// The current app.opake.cloud.* schema version this client understands. 34 + /// The current app.opake.* schema version this client understands. 35 35 /// Records with version <= this are compatible; higher versions must be rejected. 36 36 pub const SCHEMA_VERSION: u32 = 1; 37 37 38 38 /// Record types that carry a schema version number. 39 39 pub trait Versioned { 40 - fn version(&self) -> u32; 40 + fn opake_version(&self) -> u32; 41 41 } 42 42 43 43 macro_rules! impl_versioned { 44 44 ($($ty:ty),+ $(,)?) => { 45 45 $(impl Versioned for $ty { 46 - fn version(&self) -> u32 { self.version } 46 + fn opake_version(&self) -> u32 { self.opake_version } 47 47 })+ 48 48 }; 49 49 } ··· 100 100 #[test] 101 101 fn public_key_record_new_sets_defaults() { 102 102 let record = PublicKeyRecord::new(&[42u8; 32], "2026-03-01T00:00:00Z"); 103 - assert_eq!(record.version, SCHEMA_VERSION); 103 + assert_eq!(record.opake_version, SCHEMA_VERSION); 104 104 assert_eq!(record.algo, "x25519"); 105 105 assert_eq!(record.created_at, "2026-03-01T00:00:00Z"); 106 106 } ··· 111 111 let json = serde_json::to_string(&record).unwrap(); 112 112 let parsed: PublicKeyRecord = serde_json::from_str(&json).unwrap(); 113 113 114 - assert_eq!(parsed.version, record.version); 114 + assert_eq!(parsed.opake_version, record.opake_version); 115 115 assert_eq!(parsed.public_key.encoded, record.public_key.encoded); 116 116 assert_eq!(parsed.algo, "x25519"); 117 117 assert_eq!(parsed.created_at, "2026-03-01T12:00:00Z"); ··· 131 131 let json = serde_json::to_string(&directory).unwrap(); 132 132 let parsed: Directory = serde_json::from_str(&json).unwrap(); 133 133 134 - assert_eq!(parsed.version, SCHEMA_VERSION); 134 + assert_eq!(parsed.opake_version, SCHEMA_VERSION); 135 135 assert_eq!(parsed.name, "/"); 136 136 assert!(parsed.entries.is_empty()); 137 137 assert_eq!(parsed.created_at, "2026-03-01T00:00:00Z"); ··· 152 152 fn directory_with_entries_roundtrips() { 153 153 let mut directory = Directory::new("Photos".into(), "2026-03-01T00:00:00Z".into()); 154 154 directory.entries = vec![ 155 - "at://did:plc:test/app.opake.cloud.document/abc".into(), 156 - "at://did:plc:test/app.opake.cloud.directory/def".into(), 155 + "at://did:plc:test/app.opake.document/abc".into(), 156 + "at://did:plc:test/app.opake.directory/def".into(), 157 157 ]; 158 158 directory.modified_at = Some("2026-03-01T12:00:00Z".into()); 159 159 ··· 171 171 // Records created before key_history existed won't have the field. 172 172 // Verify they deserialize to an empty vec. 173 173 let json = serde_json::json!({ 174 - "version": 1, 174 + "opakeVersion": 1, 175 175 "name": "old-keyring", 176 176 "algo": "aes-256-gcm", 177 177 "members": [{
+3 -3
crates/opake-core/src/records/pair_request.rs
··· 3 3 use super::{default_version, SCHEMA_VERSION}; 4 4 use crate::atproto::AtBytes; 5 5 6 - pub const PAIR_REQUEST_COLLECTION: &str = "app.opake.cloud.pairRequest"; 6 + pub const PAIR_REQUEST_COLLECTION: &str = "app.opake.pairRequest"; 7 7 8 8 /// A device pairing request. The new device publishes its ephemeral public key 9 9 /// so the existing device can wrap the identity for secure transfer. ··· 11 11 #[serde(rename_all = "camelCase")] 12 12 pub struct PairRequest { 13 13 #[serde(default = "default_version")] 14 - pub version: u32, 14 + pub opake_version: u32, 15 15 pub ephemeral_key: AtBytes, 16 16 pub algo: String, 17 17 pub created_at: String, ··· 21 21 pub fn new(ephemeral_key_bytes: &[u8], created_at: &str) -> Self { 22 22 use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; 23 23 Self { 24 - version: SCHEMA_VERSION, 24 + opake_version: SCHEMA_VERSION, 25 25 ephemeral_key: AtBytes { 26 26 encoded: BASE64.encode(ephemeral_key_bytes), 27 27 },
+2 -2
crates/opake-core/src/records/pair_request_tests.rs
··· 3 3 #[test] 4 4 fn pair_request_new_sets_defaults() { 5 5 let record = PairRequest::new(&[42u8; 32], "2026-03-06T00:00:00Z"); 6 - assert_eq!(record.version, SCHEMA_VERSION); 6 + assert_eq!(record.opake_version, SCHEMA_VERSION); 7 7 assert_eq!(record.algo, "x25519"); 8 8 assert_eq!(record.created_at, "2026-03-06T00:00:00Z"); 9 9 } ··· 14 14 let json = serde_json::to_string(&record).unwrap(); 15 15 let parsed: PairRequest = serde_json::from_str(&json).unwrap(); 16 16 17 - assert_eq!(parsed.version, record.version); 17 + assert_eq!(parsed.opake_version, record.opake_version); 18 18 assert_eq!(parsed.ephemeral_key.encoded, record.ephemeral_key.encoded); 19 19 assert_eq!(parsed.algo, "x25519"); 20 20 assert_eq!(parsed.created_at, "2026-03-06T12:00:00Z");
+2 -2
crates/opake-core/src/records/pair_response.rs
··· 3 3 use super::{default_version, WrappedKey}; 4 4 use crate::atproto::AtBytes; 5 5 6 - pub const PAIR_RESPONSE_COLLECTION: &str = "app.opake.cloud.pairResponse"; 6 + pub const PAIR_RESPONSE_COLLECTION: &str = "app.opake.pairResponse"; 7 7 8 8 /// A device pairing response. The existing device encrypts its identity 9 9 /// to the requesting device's ephemeral key and writes this record. ··· 11 11 #[serde(rename_all = "camelCase")] 12 12 pub struct PairResponse { 13 13 #[serde(default = "default_version")] 14 - pub version: u32, 14 + pub opake_version: u32, 15 15 pub request: String, 16 16 pub wrapped_key: WrappedKey, 17 17 pub ciphertext: AtBytes,
+5 -5
crates/opake-core/src/records/pair_response_tests.rs
··· 5 5 #[test] 6 6 fn pair_response_roundtrips_through_json() { 7 7 let record = PairResponse { 8 - version: SCHEMA_VERSION, 9 - request: "at://did:plc:test/app.opake.cloud.pairRequest/abc123".into(), 8 + opake_version: SCHEMA_VERSION, 9 + request: "at://did:plc:test/app.opake.pairRequest/abc123".into(), 10 10 wrapped_key: WrappedKey { 11 11 did: "did:plc:test".into(), 12 12 ciphertext: AtBytes { ··· 27 27 let json = serde_json::to_string(&record).unwrap(); 28 28 let parsed: PairResponse = serde_json::from_str(&json).unwrap(); 29 29 30 - assert_eq!(parsed.version, SCHEMA_VERSION); 30 + assert_eq!(parsed.opake_version, SCHEMA_VERSION); 31 31 assert_eq!(parsed.request, record.request); 32 32 assert_eq!(parsed.wrapped_key.did, "did:plc:test"); 33 33 assert_eq!(parsed.wrapped_key.algo, "x25519-hkdf-a256kw"); ··· 38 38 #[test] 39 39 fn pair_response_uses_atbytes_wire_format() { 40 40 let record = PairResponse { 41 - version: SCHEMA_VERSION, 42 - request: "at://did:plc:test/app.opake.cloud.pairRequest/abc123".into(), 41 + opake_version: SCHEMA_VERSION, 42 + request: "at://did:plc:test/app.opake.pairRequest/abc123".into(), 43 43 wrapped_key: WrappedKey { 44 44 did: "did:plc:test".into(), 45 45 ciphertext: AtBytes {
+3 -3
crates/opake-core/src/records/public_key.rs
··· 3 3 use super::{default_version, SCHEMA_VERSION}; 4 4 use crate::atproto::AtBytes; 5 5 6 - pub const PUBLIC_KEY_COLLECTION: &str = "app.opake.cloud.publicKey"; 6 + pub const PUBLIC_KEY_COLLECTION: &str = "app.opake.publicKey"; 7 7 pub const PUBLIC_KEY_RKEY: &str = "self"; 8 8 9 9 /// Singleton public key record published on the user's PDS. ··· 12 12 #[serde(rename_all = "camelCase")] 13 13 pub struct PublicKeyRecord { 14 14 #[serde(default = "default_version")] 15 - pub version: u32, 15 + pub opake_version: u32, 16 16 pub public_key: AtBytes, 17 17 pub algo: String, 18 18 /// Ed25519 signing public key for DID-scoped authentication. ··· 28 28 pub fn new(public_key_bytes: &[u8], created_at: &str) -> Self { 29 29 use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; 30 30 Self { 31 - version: SCHEMA_VERSION, 31 + opake_version: SCHEMA_VERSION, 32 32 public_key: AtBytes { 33 33 encoded: BASE64.encode(public_key_bytes), 34 34 },
+6 -6
crates/opake-core/src/resolve.rs
··· 146 146 .await?; 147 147 148 148 let record: PublicKeyRecord = serde_json::from_value(entry.value)?; 149 - records::check_version(record.version)?; 149 + records::check_version(record.opake_version)?; 150 150 151 151 // Step 6: Decode and validate public key bytes 152 152 let key_bytes = record ··· 233 233 fn public_key_record_json(public_key: &X25519PublicKey) -> String { 234 234 let record = PublicKeyRecord::new(public_key, "2026-03-01T00:00:00Z"); 235 235 let entry = serde_json::json!({ 236 - "uri": "at://did:plc:target/app.opake.cloud.publicKey/self", 236 + "uri": "at://did:plc:target/app.opake.publicKey/self", 237 237 "cid": "bafyrecord", 238 238 "value": record, 239 239 }); ··· 372 372 ))); 373 373 374 374 let mut record = PublicKeyRecord::new(&[1u8; 32], "2026-03-01T00:00:00Z"); 375 - record.version = SCHEMA_VERSION + 1; 375 + record.opake_version = SCHEMA_VERSION + 1; 376 376 let entry = serde_json::json!({ 377 - "uri": "at://did:plc:future/app.opake.cloud.publicKey/self", 377 + "uri": "at://did:plc:future/app.opake.publicKey/self", 378 378 "cid": "bafy", 379 379 "value": record, 380 380 }); ··· 392 392 let pubkey = [55u8; 32]; 393 393 394 394 let put_response = serde_json::json!({ 395 - "uri": "at://did:plc:test/app.opake.cloud.publicKey/self", 395 + "uri": "at://did:plc:test/app.opake.publicKey/self", 396 396 "cid": "bafypublished", 397 397 }); 398 398 mock.enqueue(success(&put_response.to_string())); ··· 415 415 .await 416 416 .unwrap(); 417 417 418 - assert_eq!(uri, "at://did:plc:test/app.opake.cloud.publicKey/self"); 418 + assert_eq!(uri, "at://did:plc:test/app.opake.publicKey/self"); 419 419 420 420 let reqs = mock.requests(); 421 421 assert_eq!(reqs.len(), 1);
+5 -5
crates/opake-core/src/sharing/create.rs
··· 82 82 #[tokio::test] 83 83 async fn create_grant_happy_path() { 84 84 let mock = MockTransport::new(); 85 - let grant_uri = "at://did:plc:owner/app.opake.cloud.grant/tid123"; 85 + let grant_uri = "at://did:plc:owner/app.opake.grant/tid123"; 86 86 mock.enqueue(create_record_response(grant_uri)); 87 87 88 88 let mut client = mock_client(mock.clone()); ··· 92 92 let recipient_public = crate::crypto::X25519DalekPublicKey::from(&recipient_secret); 93 93 94 94 let params = GrantParams { 95 - document_uri: "at://did:plc:owner/app.opake.cloud.document/doc1", 95 + document_uri: "at://did:plc:owner/app.opake.document/doc1", 96 96 recipient_did: "did:plc:recipient", 97 97 content_key: &content_key, 98 98 recipient_public_key: recipient_public.as_bytes(), ··· 119 119 assert_eq!(record["note"], "here you go"); 120 120 assert_eq!( 121 121 record["document"], 122 - "at://did:plc:owner/app.opake.cloud.document/doc1" 122 + "at://did:plc:owner/app.opake.document/doc1" 123 123 ); 124 124 } 125 125 _ => panic!("expected JSON body"), ··· 130 130 async fn created_grant_key_is_unwrappable() { 131 131 let mock = MockTransport::new(); 132 132 mock.enqueue(create_record_response( 133 - "at://did:plc:owner/app.opake.cloud.grant/tid", 133 + "at://did:plc:owner/app.opake.grant/tid", 134 134 )); 135 135 136 136 let mut client = mock_client(mock.clone()); ··· 140 140 let recipient_public = crate::crypto::X25519DalekPublicKey::from(&recipient_secret); 141 141 142 142 let params = GrantParams { 143 - document_uri: "at://did:plc:owner/app.opake.cloud.document/doc1", 143 + document_uri: "at://did:plc:owner/app.opake.document/doc1", 144 144 recipient_did: "did:plc:recipient", 145 145 content_key: &content_key, 146 146 recipient_public_key: recipient_public.as_bytes(),
+6 -6
crates/opake-core/src/sharing/list.rs
··· 56 56 permissions: Some("read".into()), 57 57 note: Some("here you go".into()), 58 58 ..Grant::new( 59 - format!("at://{TEST_DID}/app.opake.cloud.document/{doc_rkey}"), 59 + format!("at://{TEST_DID}/app.opake.document/{doc_rkey}"), 60 60 recipient.into(), 61 61 WrappedKey { 62 62 did: recipient.into(), ··· 75 75 .iter() 76 76 .map(|(rkey, grant)| { 77 77 serde_json::json!({ 78 - "uri": format!("at://{TEST_DID}/app.opake.cloud.grant/{rkey}"), 78 + "uri": format!("at://{TEST_DID}/app.opake.grant/{rkey}"), 79 79 "cid": "bafygrant", 80 80 "value": grant, 81 81 }) ··· 113 113 let requests = mock.requests(); 114 114 assert_eq!(requests.len(), 1); 115 115 assert!(requests[0].url.contains("listRecords")); 116 - assert!(requests[0].url.contains("app.opake.cloud.grant")); 116 + assert!(requests[0].url.contains("app.opake.grant")); 117 117 } 118 118 119 119 #[tokio::test] ··· 172 172 let body = serde_json::json!({ 173 173 "records": [ 174 174 { 175 - "uri": "at://did:plc:owner/app.opake.cloud.grant/bad", 175 + "uri": "at://did:plc:owner/app.opake.grant/bad", 176 176 "cid": "bafybad", 177 177 "value": { "not": "a grant" }, 178 178 }, 179 179 { 180 - "uri": "at://did:plc:owner/app.opake.cloud.grant/good", 180 + "uri": "at://did:plc:owner/app.opake.grant/good", 181 181 "cid": "bafygood", 182 182 "value": dummy_grant("did:plc:bob", "doc1"), 183 183 }, ··· 201 201 #[tokio::test] 202 202 async fn skips_future_version() { 203 203 let mut grant = dummy_grant("did:plc:bob", "doc1"); 204 - grant.version = records::SCHEMA_VERSION + 1; 204 + grant.opake_version = records::SCHEMA_VERSION + 1; 205 205 206 206 let mock = MockTransport::new(); 207 207 mock.enqueue(list_grants_response(&[("g1", grant)], None));
+2 -2
crates/opake-core/src/sharing/mod.rs
··· 2 2 // 3 3 // A grant gives another user access to a document by wrapping the document's 4 4 // content key to the recipient's public key and storing the result as an 5 - // app.opake.cloud.grant record on the owner's PDS. 5 + // app.opake.grant record on the owner's PDS. 6 6 7 7 mod create; 8 8 mod list; ··· 12 12 pub use list::{list_grants, GrantEntry}; 13 13 pub use revoke::revoke_grant; 14 14 15 - pub const GRANT_COLLECTION: &str = "app.opake.cloud.grant"; 15 + pub const GRANT_COLLECTION: &str = "app.opake.grant";
+4 -4
crates/opake-core/src/sharing/revoke.rs
··· 7 7 use super::GRANT_COLLECTION; 8 8 9 9 /// Delete a grant record by AT-URI. Validates the collection is 10 - /// `app.opake.cloud.grant` to prevent accidental deletion of other 10 + /// `app.opake.grant` to prevent accidental deletion of other 11 11 /// record types. 12 12 pub async fn revoke_grant(client: &mut XrpcClient<impl Transport>, uri: &str) -> Result<(), Error> { 13 13 let at_uri = atproto::parse_at_uri(uri)?; ··· 55 55 }); 56 56 57 57 let mut client = mock_client(mock.clone()); 58 - let uri = format!("at://{}/app.opake.cloud.grant/tid123", TEST_DID); 58 + let uri = format!("at://{}/app.opake.grant/tid123", TEST_DID); 59 59 revoke_grant(&mut client, &uri).await.unwrap(); 60 60 61 61 let reqs = mock.requests(); ··· 76 76 async fn rejects_document_uri() { 77 77 let mock = MockTransport::new(); 78 78 let mut client = mock_client(mock); 79 - let uri = format!("at://{}/app.opake.cloud.document/abc", TEST_DID); 79 + let uri = format!("at://{}/app.opake.document/abc", TEST_DID); 80 80 let err = revoke_grant(&mut client, &uri).await.unwrap_err(); 81 81 assert!( 82 82 err.to_string().contains("expected a grant URI"), ··· 102 102 }); 103 103 104 104 let mut client = mock_client(mock); 105 - let uri = format!("at://{}/app.opake.cloud.grant/gone", TEST_DID); 105 + let uri = format!("at://{}/app.opake.grant/gone", TEST_DID); 106 106 let err = revoke_grant(&mut client, &uri).await.unwrap_err(); 107 107 assert!(matches!(err, Error::NotFound(_))); 108 108 }
+2 -2
docs/ARCHITECTURE.md
··· 246 246 247 247 ### Public Key Discovery 248 248 249 - AT Protocol DID documents only contain signing keys (secp256k1/P-256), not encryption keys. Opake publishes `app.opake.cloud.publicKey/self` singleton records on each user's PDS containing: 249 + AT Protocol DID documents only contain signing keys (secp256k1/P-256), not encryption keys. Opake publishes `app.opake.publicKey/self` singleton records on each user's PDS containing: 250 250 251 251 - **X25519 encryption public key** — used for key wrapping (sharing) 252 252 - **Ed25519 signing public key** — used for AppView authentication ··· 255 255 256 256 ## Data Model 257 257 258 - All records live under the `app.opake.cloud.*` NSID namespace. See [lexicons/README.md](../lexicons/README.md) for the schema reference and [lexicons/EXAMPLES.md](../lexicons/EXAMPLES.md) for annotated example records. 258 + All records live under the `app.opake.*` NSID namespace. See [lexicons/README.md](../lexicons/README.md) for the schema reference and [lexicons/EXAMPLES.md](../lexicons/EXAMPLES.md) for annotated example records. 259 259 260 260 ```mermaid 261 261 erDiagram
+6 -6
docs/appview.md
··· 1 1 # AppView: API & Deployment 2 2 3 - The AppView indexes `app.opake.cloud.grant` and `app.opake.cloud.keyring` records from the AT Protocol firehose and serves them via a REST API. It enables the `inbox` command — "what's been shared with me?" — without scanning every PDS in the network. 3 + The AppView indexes `app.opake.grant` and `app.opake.keyring` records from the AT Protocol firehose and serves them via a REST API. It enables the `inbox` command — "what's been shared with me?" — without scanning every PDS in the network. 4 4 5 5 ## Running Modes 6 6 ··· 63 63 1. Parse header — extract DID, timestamp, signature 64 64 2. Reject if timestamp is >60 seconds from now (replay protection) 65 65 3. Reject if `?did=` parameter doesn't match authenticated DID (scope enforcement) 66 - 4. Fetch `app.opake.cloud.publicKey/self` from the user's PDS 66 + 4. Fetch `app.opake.publicKey/self` from the user's PDS 67 67 5. Extract `signingKey` (Ed25519) from the record 68 68 6. Verify signature with `ed25519-dalek` 69 69 7. Cache verified key for 5 minutes ··· 104 104 { 105 105 "grants": [ 106 106 { 107 - "uri": "at://did:plc:owner/app.opake.cloud.grant/3abc", 107 + "uri": "at://did:plc:owner/app.opake.grant/3abc", 108 108 "ownerDid": "did:plc:owner", 109 - "documentUri": "at://did:plc:owner/app.opake.cloud.document/3xyz", 109 + "documentUri": "at://did:plc:owner/app.opake.document/3xyz", 110 110 "permissions": "read", 111 111 "note": "photos from the trip", 112 112 "createdAt": "2026-03-01T12:00:00Z" 113 113 } 114 114 ], 115 - "cursor": "2026-03-01T12:00:01Z::at://did:plc:owner/app.opake.cloud.grant/3abc" 115 + "cursor": "2026-03-01T12:00:01Z::at://did:plc:owner/app.opake.grant/3abc" 116 116 } 117 117 118 118 ``` ··· 125 125 { 126 126 "keyrings": [ 127 127 { 128 - "uri": "at://did:plc:owner/app.opake.cloud.keyring/3def", 128 + "uri": "at://did:plc:owner/app.opake.keyring/3def", 129 129 "ownerDid": "did:plc:owner", 130 130 "name": "family-photos", 131 131 "indexedAt": "2026-03-01T12:00:00Z"
+1 -1
docs/flows/documents.md
··· 29 29 CLI->>PDS: com.atproto.repo.createRecord (document) 30 30 PDS-->>CLI: { uri, cid } 31 31 32 - CLI->>User: Uploaded: at://did/app.opake.cloud.document/<tid> 32 + CLI->>User: Uploaded: at://did/app.opake.document/<tid> 33 33 ``` 34 34 35 35 ## Download (Own Files)
+1 -1
docs/flows/revisions.md
··· 135 135 AppView-->>OwnerPDS: [{ revision_uri, proposer, created_at }, ...] 136 136 ``` 137 137 138 - Without the AppView, `opake revisions <document>` can fall back to polling each keyring member's PDS for `app.opake.cloud.revision` records whose `origin` matches the document URI. Slow but functional, and zero-trust — no intermediary needed. 138 + Without the AppView, `opake revisions <document>` can fall back to polling each keyring member's PDS for `app.opake.revision` records whose `origin` matches the document URI. Slow but functional, and zero-trust — no intermediary needed.
+27 -27
lexicons/EXAMPLES.md
··· 6 6 7 7 ```json 8 8 { 9 - "$type": "app.opake.cloud.publicKey", 10 - "version": 1, 9 + "$type": "app.opake.publicKey", 10 + "opakeVersion": 1, 11 11 "publicKey": { "$bytes": "base64-encoded-32-byte-x25519-public-key" }, 12 12 "algo": "x25519", 13 13 "createdAt": "2026-03-01T10:00:00.000Z" ··· 22 22 23 23 ```json 24 24 { 25 - "$type": "app.opake.cloud.directory", 26 - "version": 1, 25 + "$type": "app.opake.directory", 26 + "opakeVersion": 1, 27 27 "name": "/", 28 28 "entries": [ 29 - "at://did:plc:alice123/app.opake.cloud.directory/3k..." 29 + "at://did:plc:alice123/app.opake.directory/3k..." 30 30 ], 31 31 "createdAt": "2026-03-01T10:00:00.000Z", 32 32 "modifiedAt": "2026-03-01T10:00:00.000Z" ··· 39 39 40 40 ```json 41 41 { 42 - "$type": "app.opake.cloud.directory", 43 - "version": 1, 42 + "$type": "app.opake.directory", 43 + "opakeVersion": 1, 44 44 "name": "Photos", 45 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" 46 + "at://did:plc:alice123/app.opake.document/3kabcd", 47 + "at://did:plc:alice123/app.opake.document/3kefgh", 48 + "at://did:plc:alice123/app.opake.directory/3kijkl" 49 49 ], 50 50 "createdAt": "2026-03-01T10:05:00.000Z", 51 51 "modifiedAt": "2026-03-01T11:30:00.000Z" ··· 58 58 59 59 ```json 60 60 { 61 - "$type": "app.opake.cloud.document", 62 - "version": 1, 61 + "$type": "app.opake.document", 62 + "opakeVersion": 1, 63 63 "name": "tax-return-2025.pdf", 64 64 "mimeType": "application/pdf", 65 65 "size": 284619, ··· 70 70 "size": 284640 71 71 }, 72 72 "encryption": { 73 - "$type": "app.opake.cloud.document#directEncryption", 73 + "$type": "app.opake.document#directEncryption", 74 74 "envelope": { 75 75 "algo": "aes-256-gcm", 76 76 "nonce": { "$bytes": "base64-encoded-12-byte-nonce" }, ··· 98 98 99 99 ```json 100 100 { 101 - "$type": "app.opake.cloud.grant", 102 - "version": 1, 103 - "document": "at://did:plc:alice123/app.opake.cloud.document/3k...", 101 + "$type": "app.opake.grant", 102 + "opakeVersion": 1, 103 + "document": "at://did:plc:alice123/app.opake.document/3k...", 104 104 "recipient": "did:plc:bob456", 105 105 "wrappedKey": { 106 106 "did": "did:plc:bob456", ··· 131 131 132 132 ```json 133 133 { 134 - "$type": "app.opake.cloud.keyring", 135 - "version": 1, 134 + "$type": "app.opake.keyring", 135 + "opakeVersion": 1, 136 136 "name": "family-photos", 137 137 "description": "Shared photo collection for the family", 138 138 "algo": "aes-256-gcm", ··· 162 162 163 163 ```json 164 164 { 165 - "$type": "app.opake.cloud.document", 166 - "version": 1, 165 + "$type": "app.opake.document", 166 + "opakeVersion": 1, 167 167 "name": "beach-sunset.jpg", 168 168 "mimeType": "image/jpeg", 169 169 "size": 3841029, ··· 174 174 "size": 3841056 175 175 }, 176 176 "encryption": { 177 - "$type": "app.opake.cloud.document#keyringEncryption", 177 + "$type": "app.opake.document#keyringEncryption", 178 178 "keyringRef": { 179 - "keyring": "at://did:plc:alice123/app.opake.cloud.keyring/3k...", 179 + "keyring": "at://did:plc:alice123/app.opake.keyring/3k...", 180 180 "wrappedContentKey": { "$bytes": "base64-content-key-encrypted-with-group-key" }, 181 181 "rotation": 0 182 182 }, ··· 215 215 216 216 ```json 217 217 { 218 - "$type": "app.opake.cloud.pairRequest", 219 - "version": 1, 218 + "$type": "app.opake.pairRequest", 219 + "opakeVersion": 1, 220 220 "ephemeralKey": { "$bytes": "base64-encoded-32-byte-x25519-ephemeral-public-key" }, 221 221 "algo": "x25519", 222 222 "createdAt": "2026-03-06T14:00:00.000Z" ··· 231 231 232 232 ```json 233 233 { 234 - "$type": "app.opake.cloud.pairResponse", 235 - "version": 1, 236 - "request": "at://did:plc:alice123/app.opake.cloud.pairRequest/3kabcd", 234 + "$type": "app.opake.pairResponse", 235 + "opakeVersion": 1, 236 + "request": "at://did:plc:alice123/app.opake.pairRequest/3kabcd", 237 237 "wrappedKey": { 238 238 "did": "did:plc:alice123", 239 239 "ciphertext": { "$bytes": "base64-content-key-wrapped-to-ephemeral-pubkey" },
+9 -9
lexicons/README.md
··· 1 - # app.opake.cloud.* Lexicon Schemas 1 + # app.opake.* Lexicon Schemas 2 2 3 3 An encrypted personal cloud built on AT Protocol. 4 4 ··· 14 14 15 15 | NSID | Type | Purpose | 16 16 |------|------|---------| 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 | 19 - | `app.opake.cloud.document` | record | An encrypted file/document with metadata | 20 - | `app.opake.cloud.publicKey` | record | Singleton X25519 encryption public key (rkey: `self`) for key discovery | 21 - | `app.opake.cloud.keyring` | record | A named group with a shared symmetric key, wrapped to each member | 22 - | `app.opake.cloud.grant` | record | A share grant — gives a DID access to a specific document's key | 23 - | `app.opake.cloud.pairRequest` | record | Ephemeral public key from a new device requesting identity transfer | 24 - | `app.opake.cloud.pairResponse` | record | Encrypted identity payload sent in response to a pair request | 17 + | `app.opake.defs` | defs | Shared type definitions (encryption envelope, wrapped key, etc.) | 18 + | `app.opake.directory` | record | A directory containing an ordered list of child document/directory AT-URIs | 19 + | `app.opake.document` | record | An encrypted file/document with metadata | 20 + | `app.opake.publicKey` | record | Singleton X25519 encryption public key (rkey: `self`) for key discovery | 21 + | `app.opake.keyring` | record | A named group with a shared symmetric key, wrapped to each member | 22 + | `app.opake.grant` | record | A share grant — gives a DID access to a specific document's key | 23 + | `app.opake.pairRequest` | record | Ephemeral public key from a new device requesting identity transfer | 24 + | `app.opake.pairResponse` | record | Encrypted identity payload sent in response to a pair request | 25 25 26 26 ## Flow: Sharing a file with another DID 27 27
+2 -2
lexicons/app.opake.cloud.defs.json lexicons/app.opake.defs.json
··· 1 1 { 2 2 "lexicon": 1, 3 - "id": "app.opake.cloud.defs", 3 + "id": "app.opake.defs", 4 4 "defs": { 5 5 "wrappedKey": { 6 6 "type": "object", ··· 60 60 "keyring": { 61 61 "type": "string", 62 62 "format": "at-uri", 63 - "description": "AT URI of the app.opake.cloud.keyring record." 63 + "description": "AT URI of the app.opake.keyring record." 64 64 }, 65 65 "wrappedContentKey": { 66 66 "type": "bytes",
+4 -4
lexicons/app.opake.cloud.directory.json lexicons/app.opake.directory.json
··· 1 1 { 2 2 "lexicon": 1, 3 - "id": "app.opake.cloud.directory", 3 + "id": "app.opake.directory", 4 4 "defs": { 5 5 "main": { 6 6 "type": "record", ··· 8 8 "key": "any", 9 9 "record": { 10 10 "type": "object", 11 - "required": ["version", "name", "createdAt"], 11 + "required": ["opakeVersion", "name", "createdAt"], 12 12 "properties": { 13 - "version": { 13 + "opakeVersion": { 14 14 "type": "integer", 15 - "description": "Schema version for the app.opake.cloud.* namespace.", 15 + "description": "Schema version for the app.opake.* namespace.", 16 16 "minimum": 1 17 17 }, 18 18 "name": {
+7 -7
lexicons/app.opake.cloud.document.json lexicons/app.opake.document.json
··· 1 1 { 2 2 "lexicon": 1, 3 - "id": "app.opake.cloud.document", 3 + "id": "app.opake.document", 4 4 "defs": { 5 5 "main": { 6 6 "type": "record", ··· 8 8 "key": "tid", 9 9 "record": { 10 10 "type": "object", 11 - "required": ["version", "name", "blob", "encryption", "createdAt"], 11 + "required": ["opakeVersion", "name", "blob", "encryption", "createdAt"], 12 12 "properties": { 13 - "version": { 13 + "opakeVersion": { 14 14 "type": "integer", 15 - "description": "Schema version for the app.opake.cloud.* namespace. Clients should reject records with a version they do not understand.", 15 + "description": "Schema version for the app.opake.* namespace. Clients should reject records with a version they do not understand.", 16 16 "minimum": 1 17 17 }, 18 18 "name": { ··· 57 57 }, 58 58 "visibility": { 59 59 "type": "ref", 60 - "ref": "app.opake.cloud.defs#visibility" 60 + "ref": "app.opake.defs#visibility" 61 61 }, 62 62 "createdAt": { 63 63 "type": "string", ··· 78 78 "properties": { 79 79 "envelope": { 80 80 "type": "ref", 81 - "ref": "app.opake.cloud.defs#encryptionEnvelope" 81 + "ref": "app.opake.defs#encryptionEnvelope" 82 82 } 83 83 } 84 84 }, ··· 90 90 "properties": { 91 91 "keyringRef": { 92 92 "type": "ref", 93 - "ref": "app.opake.cloud.defs#keyringRef" 93 + "ref": "app.opake.defs#keyringRef" 94 94 }, 95 95 "algo": { 96 96 "type": "string",
+6 -6
lexicons/app.opake.cloud.grant.json lexicons/app.opake.grant.json
··· 1 1 { 2 2 "lexicon": 1, 3 - "id": "app.opake.cloud.grant", 3 + "id": "app.opake.grant", 4 4 "defs": { 5 5 "main": { 6 6 "type": "record", ··· 8 8 "key": "tid", 9 9 "record": { 10 10 "type": "object", 11 - "required": ["version", "document", "recipient", "wrappedKey", "createdAt"], 11 + "required": ["opakeVersion", "document", "recipient", "wrappedKey", "createdAt"], 12 12 "properties": { 13 - "version": { 13 + "opakeVersion": { 14 14 "type": "integer", 15 - "description": "Schema version for the app.opake.cloud.* namespace. Clients should reject records with a version they do not understand.", 15 + "description": "Schema version for the app.opake.* namespace. Clients should reject records with a version they do not understand.", 16 16 "minimum": 1 17 17 }, 18 18 "document": { 19 19 "type": "string", 20 20 "format": "at-uri", 21 - "description": "AT URI of the app.opake.cloud.document record being shared." 21 + "description": "AT URI of the app.opake.document record being shared." 22 22 }, 23 23 "recipient": { 24 24 "type": "string", ··· 27 27 }, 28 28 "wrappedKey": { 29 29 "type": "ref", 30 - "ref": "app.opake.cloud.defs#wrappedKey", 30 + "ref": "app.opake.defs#wrappedKey", 31 31 "description": "The document's content encryption key, wrapped to the recipient's DID public key." 32 32 }, 33 33 "permissions": {
+6 -6
lexicons/app.opake.cloud.keyring.json lexicons/app.opake.keyring.json
··· 1 1 { 2 2 "lexicon": 1, 3 - "id": "app.opake.cloud.keyring", 3 + "id": "app.opake.keyring", 4 4 "defs": { 5 5 "keyHistoryEntry": { 6 6 "type": "object", ··· 13 13 }, 14 14 "members": { 15 15 "type": "array", 16 - "items": { "type": "ref", "ref": "app.opake.cloud.defs#wrappedKey" }, 16 + "items": { "type": "ref", "ref": "app.opake.defs#wrappedKey" }, 17 17 "minLength": 1, 18 18 "maxLength": 256 19 19 } ··· 25 25 "key": "tid", 26 26 "record": { 27 27 "type": "object", 28 - "required": ["version", "name", "algo", "members", "createdAt"], 28 + "required": ["opakeVersion", "name", "algo", "members", "createdAt"], 29 29 "properties": { 30 - "version": { 30 + "opakeVersion": { 31 31 "type": "integer", 32 - "description": "Schema version for the app.opake.cloud.* namespace. Clients should reject records with a version they do not understand.", 32 + "description": "Schema version for the app.opake.* namespace. Clients should reject records with a version they do not understand.", 33 33 "minimum": 1 34 34 }, 35 35 "name": { ··· 50 50 "members": { 51 51 "type": "array", 52 52 "description": "The group key wrapped to each member's DID public key. Each entry allows that DID to recover the group key.", 53 - "items": { "type": "ref", "ref": "app.opake.cloud.defs#wrappedKey" }, 53 + "items": { "type": "ref", "ref": "app.opake.defs#wrappedKey" }, 54 54 "minLength": 1, 55 55 "maxLength": 256 56 56 },
+3 -3
lexicons/app.opake.cloud.pairRequest.json lexicons/app.opake.pairRequest.json
··· 1 1 { 2 2 "lexicon": 1, 3 - "id": "app.opake.cloud.pairRequest", 3 + "id": "app.opake.pairRequest", 4 4 "description": "A device pairing request. Created by a new device that needs the existing encryption identity transferred from another device. The ephemeral key is used for a one-time DH key exchange to protect the identity in transit.", 5 5 "defs": { 6 6 "main": { ··· 8 8 "key": "tid", 9 9 "record": { 10 10 "type": "object", 11 - "required": ["version", "ephemeralKey", "algo", "createdAt"], 11 + "required": ["opakeVersion", "ephemeralKey", "algo", "createdAt"], 12 12 "properties": { 13 - "version": { 13 + "opakeVersion": { 14 14 "type": "integer", 15 15 "minimum": 1, 16 16 "description": "Schema version for forward compatibility."
+4 -4
lexicons/app.opake.cloud.pairResponse.json lexicons/app.opake.pairResponse.json
··· 1 1 { 2 2 "lexicon": 1, 3 - "id": "app.opake.cloud.pairResponse", 3 + "id": "app.opake.pairResponse", 4 4 "description": "A device pairing response. Created by the existing device after approving a pairing request. Contains the encryption identity encrypted to the requesting device's ephemeral key via DH + HKDF + AES-KW + AES-256-GCM.", 5 5 "defs": { 6 6 "main": { ··· 8 8 "key": "tid", 9 9 "record": { 10 10 "type": "object", 11 - "required": ["version", "request", "wrappedKey", "ciphertext", "nonce", "algo", "createdAt"], 11 + "required": ["opakeVersion", "request", "wrappedKey", "ciphertext", "nonce", "algo", "createdAt"], 12 12 "properties": { 13 - "version": { 13 + "opakeVersion": { 14 14 "type": "integer", 15 15 "minimum": 1, 16 16 "description": "Schema version for forward compatibility." ··· 22 22 }, 23 23 "wrappedKey": { 24 24 "type": "ref", 25 - "ref": "app.opake.cloud.defs#wrappedKey", 25 + "ref": "app.opake.defs#wrappedKey", 26 26 "description": "Content encryption key wrapped to the ephemeral key from the pair request." 27 27 }, 28 28 "ciphertext": {
+3 -3
lexicons/app.opake.cloud.publicKey.json lexicons/app.opake.publicKey.json
··· 1 1 { 2 2 "lexicon": 1, 3 - "id": "app.opake.cloud.publicKey", 3 + "id": "app.opake.publicKey", 4 4 "description": "X25519 encryption public key for an Opake user. Singleton record (rkey: 'self') published on the user's PDS to enable key discovery for sharing.", 5 5 "defs": { 6 6 "main": { ··· 8 8 "key": "literal:self", 9 9 "record": { 10 10 "type": "object", 11 - "required": ["version", "publicKey", "algo", "createdAt"], 11 + "required": ["opakeVersion", "publicKey", "algo", "createdAt"], 12 12 "properties": { 13 - "version": { 13 + "opakeVersion": { 14 14 "type": "integer", 15 15 "minimum": 1, 16 16 "description": "Schema version for forward compatibility."
+3 -3
web/src/lib/oauth.ts
··· 305 305 const url = `${base}/xrpc/com.atproto.repo.putRecord`; 306 306 307 307 const record: Record<string, unknown> = { 308 - $type: "app.opake.cloud.publicKey", 309 - version: 1, 308 + $type: "app.opake.publicKey", 309 + opakeVersion: 1, 310 310 algo: "x25519", 311 311 publicKey: { $bytes: publicKey }, 312 312 createdAt: new Date().toISOString(), ··· 318 318 319 319 const jsonBody = JSON.stringify({ 320 320 repo: did, 321 - collection: "app.opake.cloud.publicKey", 321 + collection: "app.opake.publicKey", 322 322 rkey: "self", 323 323 record, 324 324 });
+4 -4
web/src/lib/pairing.ts
··· 30 30 nonce: AtBytes; 31 31 } 32 32 33 - const PAIR_REQUEST_COLLECTION = "app.opake.cloud.pairRequest"; 34 - const PAIR_RESPONSE_COLLECTION = "app.opake.cloud.pairResponse"; 33 + const PAIR_REQUEST_COLLECTION = "app.opake.pairRequest"; 34 + const PAIR_RESPONSE_COLLECTION = "app.opake.pairResponse"; 35 35 const SCHEMA_VERSION = 1; 36 36 37 37 // --------------------------------------------------------------------------- ··· 49 49 50 50 const record = { 51 51 $type: PAIR_REQUEST_COLLECTION, 52 - version: SCHEMA_VERSION, 52 + opakeVersion: SCHEMA_VERSION, 53 53 ephemeralKey: { $bytes: uint8ArrayToBase64(ephemeralPubKey) }, 54 54 algo: "x25519", 55 55 createdAt: new Date().toISOString(), ··· 205 205 206 206 const record = { 207 207 $type: PAIR_RESPONSE_COLLECTION, 208 - version: SCHEMA_VERSION, 208 + opakeVersion: SCHEMA_VERSION, 209 209 request: requestUri, 210 210 wrappedKey, 211 211 ciphertext: { $bytes: uint8ArrayToBase64(encrypted.ciphertext) },
+1 -1
web/src/stores/auth.ts
··· 68 68 await authenticatedXrpc( 69 69 { 70 70 pdsUrl, 71 - lexicon: `com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=app.opake.cloud.publicKey&rkey=self`, 71 + lexicon: `com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=app.opake.publicKey&rkey=self`, 72 72 method: "GET", 73 73 }, 74 74 session,