New singleton record type (app.opake.cloud.publicKey, rkey: "self") for publishing X25519 encryption public keys on a user's PDS. Uses AtBytes for wire-compatible $bytes serialization.
+2
-2
crates/opake-cli/src/commands/download.rs
+2
-2
crates/opake-cli/src/commands/download.rs
···
43
43
44
44
impl Execute for DownloadCommand {
45
45
async fn execute(self) -> Result<Option<Session>> {
46
-
let mut client = session::load_client()?;
47
-
let id = identity::load_identity().context("run `opake login` first")?;
46
+
let mut client = session::load_client_default()?;
47
+
let id = identity::load_identity_default().context("run `opake login` first")?;
48
48
let private_key = id.private_key_bytes()?;
49
49
50
50
let uri = documents::resolve_uri(&mut client, &self.reference).await?;
+23
-6
crates/opake-cli/src/commands/login.rs
+23
-6
crates/opake-cli/src/commands/login.rs
···
4
4
use opake_core::client::{Session, XrpcClient};
5
5
6
6
use crate::commands::Execute;
7
-
use crate::config;
7
+
use crate::config::{self, AccountConfig};
8
8
use crate::identity;
9
9
use crate::transport::ReqwestTransport;
10
10
use crate::utils::prefixed_get_env;
11
+
12
+
use std::collections::BTreeMap;
11
13
12
14
/// Resolve password from env var or a fallback function (e.g. stdin prompt).
13
15
pub fn resolve_password(
···
55
57
56
58
let session = client.login(self.identifier.trim(), &password).await?;
57
59
58
-
// PDS URL is login-specific config — write it here, session
59
-
// persistence is handled by the dispatch layer.
60
-
config::save_config(&config::Config {
61
-
pds_url: self.pds.clone(),
62
-
})?;
60
+
// Register this account in the config. Merge into existing accounts
61
+
// if present, set as default if it's the first one.
62
+
let mut cfg = config::load_config().unwrap_or(config::Config {
63
+
default_did: None,
64
+
accounts: BTreeMap::new(),
65
+
});
66
+
67
+
cfg.accounts.insert(
68
+
session.did.clone(),
69
+
AccountConfig {
70
+
pds_url: self.pds.clone(),
71
+
handle: session.handle.clone(),
72
+
},
73
+
);
74
+
75
+
if cfg.default_did.is_none() {
76
+
cfg.default_did = Some(session.did.clone());
77
+
}
78
+
79
+
config::save_config(&cfg)?;
63
80
64
81
let (_, generated) =
65
82
identity::ensure_identity(&session.did, &mut opake_core::crypto::OsRng)?;
+1
-1
crates/opake-cli/src/commands/ls.rs
+1
-1
crates/opake-cli/src/commands/ls.rs
···
73
73
74
74
impl Execute for LsCommand {
75
75
async fn execute(self) -> Result<Option<Session>> {
76
-
let mut client = session::load_client()?;
76
+
let mut client = session::load_client_default()?;
77
77
let mut entries = documents::list_documents(&mut client).await?;
78
78
79
79
if let Some(ref tag) = self.tag {
+1
-1
crates/opake-cli/src/commands/rm.rs
+1
-1
crates/opake-cli/src/commands/rm.rs
···
19
19
20
20
impl Execute for RmCommand {
21
21
async fn execute(self) -> Result<Option<Session>> {
22
-
let mut client = session::load_client()?;
22
+
let mut client = session::load_client_default()?;
23
23
let uri = documents::resolve_uri(&mut client, &self.reference).await?;
24
24
25
25
if !self.yes {
+2
-2
crates/opake-cli/src/commands/upload.rs
+2
-2
crates/opake-cli/src/commands/upload.rs
···
34
34
anyhow::bail!("--keyring not yet supported (tracking: chainlink #21)");
35
35
}
36
36
37
-
let mut client = session::load_client()?;
38
-
let id = identity::load_identity()?;
37
+
let mut client = session::load_client_default()?;
38
+
let id = identity::load_identity_default()?;
39
39
let owner_pubkey = id.public_key_bytes()?;
40
40
41
41
let plaintext =
+157
-26
crates/opake-cli/src/config.rs
+157
-26
crates/opake-cli/src/config.rs
···
1
+
use std::collections::BTreeMap;
1
2
use std::fs;
2
3
use std::path::PathBuf;
3
4
···
5
6
use serde::de::DeserializeOwned;
6
7
use serde::{Deserialize, Serialize};
7
8
8
-
/// Persistent CLI configuration (PDS URL, preferences).
9
+
/// Persistent CLI configuration — tracks all logged-in accounts.
9
10
#[derive(Debug, Serialize, Deserialize)]
10
11
pub struct Config {
12
+
pub default_did: Option<String>,
13
+
#[serde(default)]
14
+
pub accounts: BTreeMap<String, AccountConfig>,
15
+
}
16
+
17
+
/// Per-account configuration stored in the global config.toml.
18
+
#[derive(Debug, Serialize, Deserialize)]
19
+
pub struct AccountConfig {
11
20
pub pds_url: String,
21
+
pub handle: String,
12
22
}
13
23
14
24
/// Where Opake stores its state on disk. Overridable via `OPAKE_DATA_DIR`
···
31
41
Ok(())
32
42
}
33
43
34
-
/// Serialize a value to a JSON file in the data directory.
35
-
pub fn save_json<T: Serialize>(filename: &str, value: &T) -> anyhow::Result<()> {
36
-
ensure_data_dir()?;
37
-
let json = serde_json::to_string_pretty(value)
38
-
.with_context(|| format!("failed to serialize {filename}"))?;
39
-
fs::write(data_dir().join(filename), json)
40
-
.with_context(|| format!("failed to write {filename}"))
41
-
}
42
-
43
-
/// Deserialize a value from a JSON file in the data directory.
44
-
pub fn load_json<T: DeserializeOwned>(filename: &str) -> anyhow::Result<T> {
45
-
let path = data_dir().join(filename);
46
-
let content = fs::read_to_string(&path)
47
-
.with_context(|| format!("no {filename} found: run `opake login` first"))?;
48
-
serde_json::from_str(&content).with_context(|| format!("failed to parse {filename}"))
49
-
}
50
-
51
44
pub fn save_config(config: &Config) -> anyhow::Result<()> {
52
45
ensure_data_dir()?;
53
46
let content = toml::to_string_pretty(config).context("failed to serialize config")?;
···
61
54
toml::from_str(&content).context("failed to parse config.toml")
62
55
}
63
56
57
+
/// Make a DID safe for use as a directory name: `did:plc:abc` → `did_plc_abc`.
58
+
pub fn sanitize_did(did: &str) -> String {
59
+
did.replace(':', "_")
60
+
}
61
+
62
+
/// Path to an account's private data directory.
63
+
pub fn account_dir(did: &str) -> PathBuf {
64
+
data_dir().join("accounts").join(sanitize_did(did))
65
+
}
66
+
67
+
/// Create the account directory (and parents) if it doesn't exist.
68
+
pub fn ensure_account_dir(did: &str) -> anyhow::Result<()> {
69
+
let dir = account_dir(did);
70
+
if !dir.exists() {
71
+
fs::create_dir_all(&dir)
72
+
.with_context(|| format!("failed to create account directory: {}", dir.display()))?;
73
+
}
74
+
Ok(())
75
+
}
76
+
77
+
/// Serialize a value to a JSON file inside an account's directory.
78
+
pub fn save_account_json<T: Serialize>(did: &str, filename: &str, value: &T) -> anyhow::Result<()> {
79
+
ensure_account_dir(did)?;
80
+
let json = serde_json::to_string_pretty(value)
81
+
.with_context(|| format!("failed to serialize {filename}"))?;
82
+
fs::write(account_dir(did).join(filename), json)
83
+
.with_context(|| format!("failed to write {filename} for {did}"))
84
+
}
85
+
86
+
/// Deserialize a value from a JSON file inside an account's directory.
87
+
pub fn load_account_json<T: DeserializeOwned>(did: &str, filename: &str) -> anyhow::Result<T> {
88
+
let path = account_dir(did).join(filename);
89
+
let content = fs::read_to_string(&path)
90
+
.with_context(|| format!("no {filename} for {did}: run `opake login` first"))?;
91
+
serde_json::from_str(&content).with_context(|| format!("failed to parse {filename} for {did}"))
92
+
}
93
+
64
94
#[cfg(test)]
65
95
mod tests {
66
96
use super::*;
67
97
use crate::utils::test_harness::with_test_dir;
68
98
99
+
fn test_config(did: &str, pds_url: &str, handle: &str) -> Config {
100
+
let mut accounts = BTreeMap::new();
101
+
accounts.insert(
102
+
did.to_string(),
103
+
AccountConfig {
104
+
pds_url: pds_url.into(),
105
+
handle: handle.into(),
106
+
},
107
+
);
108
+
Config {
109
+
default_did: Some(did.to_string()),
110
+
accounts,
111
+
}
112
+
}
113
+
69
114
#[test]
70
115
fn save_and_load_config_roundtrip() {
71
116
with_test_dir(|_| {
117
+
let config = test_config("did:plc:alice", "https://pds.test", "alice.test");
118
+
save_config(&config).unwrap();
119
+
120
+
let loaded = load_config().unwrap();
121
+
assert_eq!(loaded.default_did.unwrap(), "did:plc:alice");
122
+
let acc = loaded.accounts.get("did:plc:alice").unwrap();
123
+
assert_eq!(acc.pds_url, "https://pds.test");
124
+
assert_eq!(acc.handle, "alice.test");
125
+
});
126
+
}
127
+
128
+
#[test]
129
+
fn config_with_multiple_accounts_roundtrips() {
130
+
with_test_dir(|_| {
131
+
let mut accounts = BTreeMap::new();
132
+
accounts.insert(
133
+
"did:plc:alice".into(),
134
+
AccountConfig {
135
+
pds_url: "https://pds.alice".into(),
136
+
handle: "alice.test".into(),
137
+
},
138
+
);
139
+
accounts.insert(
140
+
"did:plc:bob".into(),
141
+
AccountConfig {
142
+
pds_url: "https://pds.bob".into(),
143
+
handle: "bob.test".into(),
144
+
},
145
+
);
72
146
let config = Config {
73
-
pds_url: "https://pds.test".into(),
147
+
default_did: Some("did:plc:alice".into()),
148
+
accounts,
74
149
};
75
150
save_config(&config).unwrap();
76
151
77
152
let loaded = load_config().unwrap();
78
-
assert_eq!(loaded.pds_url, "https://pds.test");
153
+
assert_eq!(loaded.accounts.len(), 2);
154
+
assert_eq!(
155
+
loaded.accounts.get("did:plc:bob").unwrap().handle,
156
+
"bob.test"
157
+
);
158
+
});
159
+
}
160
+
161
+
#[test]
162
+
fn sanitize_did_replaces_colons() {
163
+
assert_eq!(sanitize_did("did:plc:abc123"), "did_plc_abc123");
164
+
}
165
+
166
+
#[test]
167
+
fn sanitize_did_handles_did_web() {
168
+
assert_eq!(sanitize_did("did:web:example.com"), "did_web_example.com");
169
+
}
170
+
171
+
#[test]
172
+
fn account_dir_uses_sanitized_did() {
173
+
with_test_dir(|_| {
174
+
let dir = account_dir("did:plc:test");
175
+
assert!(dir.ends_with("accounts/did_plc_test"));
176
+
});
177
+
}
178
+
179
+
#[test]
180
+
fn save_and_load_account_json_roundtrip() {
181
+
with_test_dir(|_| {
182
+
let did = "did:plc:test";
183
+
let data = serde_json::json!({"key": "value"});
184
+
save_account_json(did, "test.json", &data).unwrap();
185
+
186
+
let loaded: serde_json::Value = load_account_json(did, "test.json").unwrap();
187
+
assert_eq!(loaded["key"], "value");
188
+
});
189
+
}
190
+
191
+
#[test]
192
+
fn load_account_json_missing_file_errors() {
193
+
with_test_dir(|_| {
194
+
let result: anyhow::Result<serde_json::Value> =
195
+
load_account_json("did:plc:nobody", "nope.json");
196
+
let err = result.unwrap_err().to_string();
197
+
assert!(err.contains("opake login"), "expected login hint: {err}");
198
+
});
199
+
}
200
+
201
+
#[test]
202
+
fn ensure_account_dir_creates_nested_dirs() {
203
+
with_test_dir(|_| {
204
+
let did = "did:plc:nested";
205
+
ensure_account_dir(did).unwrap();
206
+
assert!(account_dir(did).exists());
79
207
});
80
208
}
81
209
···
111
239
}
112
240
113
241
#[test]
114
-
fn load_config_rejects_valid_toml_wrong_schema() {
242
+
fn load_config_ignores_unknown_keys() {
115
243
with_test_dir(|_| {
116
244
ensure_data_dir().unwrap();
117
245
fs::write(data_dir().join("config.toml"), "[section]\nkey = 42\n").unwrap();
118
-
let result = load_config();
119
-
assert!(result.is_err());
246
+
// New Config has all optional/default fields — unknown keys are ignored
247
+
let loaded = load_config().unwrap();
248
+
assert!(loaded.default_did.is_none());
249
+
assert!(loaded.accounts.is_empty());
120
250
});
121
251
}
122
252
123
253
#[test]
124
-
fn load_config_rejects_empty_file() {
254
+
fn load_config_empty_file_gives_defaults() {
125
255
with_test_dir(|_| {
126
256
ensure_data_dir().unwrap();
127
257
fs::write(data_dir().join("config.toml"), "").unwrap();
128
-
let result = load_config();
129
-
assert!(result.is_err());
258
+
let loaded = load_config().unwrap();
259
+
assert!(loaded.default_did.is_none());
260
+
assert!(loaded.accounts.is_empty());
130
261
});
131
262
}
132
263
+72
-53
crates/opake-cli/src/identity.rs
+72
-53
crates/opake-cli/src/identity.rs
···
9
9
10
10
use crate::config;
11
11
12
-
const FILENAME: &str = "identity.json";
13
-
14
12
/// X25519 encryption keypair, stored as base64 in `identity.json`.
15
13
#[derive(Debug, Serialize, Deserialize)]
16
14
pub struct Identity {
···
41
39
}
42
40
}
43
41
44
-
pub fn save_identity(identity: &Identity) -> anyhow::Result<()> {
45
-
config::save_json(FILENAME, identity)
42
+
pub fn save_identity(did: &str, identity: &Identity) -> anyhow::Result<()> {
43
+
config::save_account_json(did, "identity.json", identity)
46
44
}
47
45
48
-
pub fn load_identity() -> anyhow::Result<Identity> {
49
-
config::load_json(FILENAME)
46
+
pub fn load_identity(did: &str) -> anyhow::Result<Identity> {
47
+
config::load_account_json(did, "identity.json")
50
48
}
51
49
52
-
/// Return the existing identity if it matches `did`, otherwise generate a new
53
-
/// X25519 keypair, save it, and return it. The boolean indicates whether a new
54
-
/// keypair was generated.
50
+
/// Load identity for the default account.
51
+
pub fn load_identity_default() -> anyhow::Result<Identity> {
52
+
let config = config::load_config()?;
53
+
let did = config
54
+
.default_did
55
+
.ok_or_else(|| anyhow::anyhow!("no default account: run `opake login` first"))?;
56
+
load_identity(&did)
57
+
}
58
+
59
+
/// Return the existing identity if present, otherwise generate a new
60
+
/// X25519 keypair, save it, and return it. The boolean indicates whether
61
+
/// a new keypair was generated.
55
62
pub fn ensure_identity(
56
63
did: &str,
57
64
rng: &mut (impl CryptoRng + RngCore),
58
65
) -> anyhow::Result<(Identity, bool)> {
59
-
if let Ok(existing) = load_identity() {
66
+
if let Ok(existing) = load_identity(did) {
60
67
if existing.did == did {
61
68
return Ok((existing, false));
62
69
}
···
74
81
public_key: BASE64.encode(public_key.as_bytes()),
75
82
private_key: BASE64.encode(private_secret.to_bytes()),
76
83
};
77
-
save_identity(&identity)?;
84
+
save_identity(did, &identity)?;
78
85
Ok((identity, true))
79
86
}
80
87
···
83
90
use super::*;
84
91
use crate::utils::test_harness::with_test_dir;
85
92
use opake_core::crypto::OsRng;
86
-
use std::fs;
93
+
use std::collections::BTreeMap;
87
94
88
-
fn file_path() -> std::path::PathBuf {
89
-
config::data_dir().join(FILENAME)
95
+
fn setup_account(did: &str) {
96
+
let mut accounts = BTreeMap::new();
97
+
accounts.insert(
98
+
did.to_string(),
99
+
config::AccountConfig {
100
+
pds_url: "https://pds.test".into(),
101
+
handle: "test.handle".into(),
102
+
},
103
+
);
104
+
config::save_config(&config::Config {
105
+
default_did: Some(did.to_string()),
106
+
accounts,
107
+
})
108
+
.unwrap();
90
109
}
91
110
92
111
#[test]
93
112
fn save_and_load_identity_roundtrip() {
94
113
with_test_dir(|_| {
114
+
let did = "did:plc:test";
115
+
setup_account(did);
95
116
let identity = Identity {
96
-
did: "did:plc:test".into(),
117
+
did: did.into(),
97
118
public_key: BASE64.encode([1u8; 32]),
98
119
private_key: BASE64.encode([2u8; 32]),
99
120
};
100
-
save_identity(&identity).unwrap();
121
+
save_identity(did, &identity).unwrap();
101
122
102
-
let loaded = load_identity().unwrap();
123
+
let loaded = load_identity(did).unwrap();
103
124
assert_eq!(loaded.did, identity.did);
104
125
assert_eq!(loaded.public_key, identity.public_key);
105
126
assert_eq!(loaded.private_key, identity.private_key);
···
112
133
#[test]
113
134
fn ensure_identity_generates_when_missing() {
114
135
with_test_dir(|_| {
115
-
let (identity, generated) = ensure_identity("did:plc:new", &mut OsRng).unwrap();
136
+
let did = "did:plc:new";
137
+
setup_account(did);
138
+
let (identity, generated) = ensure_identity(did, &mut OsRng).unwrap();
116
139
assert!(generated);
117
-
assert_eq!(identity.did, "did:plc:new");
140
+
assert_eq!(identity.did, did);
118
141
assert_eq!(identity.public_key_bytes().unwrap().len(), 32);
119
142
assert_eq!(identity.private_key_bytes().unwrap().len(), 32);
120
143
});
···
123
146
#[test]
124
147
fn ensure_identity_returns_existing_when_did_matches() {
125
148
with_test_dir(|_| {
126
-
let (first, generated) = ensure_identity("did:plc:same", &mut OsRng).unwrap();
149
+
let did = "did:plc:same";
150
+
setup_account(did);
151
+
let (first, generated) = ensure_identity(did, &mut OsRng).unwrap();
127
152
assert!(generated);
128
153
129
-
let (second, generated) = ensure_identity("did:plc:same", &mut OsRng).unwrap();
154
+
let (second, generated) = ensure_identity(did, &mut OsRng).unwrap();
130
155
assert!(!generated);
131
156
assert_eq!(first.public_key, second.public_key);
132
157
assert_eq!(first.private_key, second.private_key);
···
134
159
}
135
160
136
161
#[test]
137
-
fn ensure_identity_regenerates_on_did_mismatch() {
162
+
fn load_identity_default_works() {
138
163
with_test_dir(|_| {
139
-
let (first, _) = ensure_identity("did:plc:alice", &mut OsRng).unwrap();
140
-
let (second, generated) = ensure_identity("did:plc:bob", &mut OsRng).unwrap();
141
-
assert!(generated);
142
-
assert_eq!(second.did, "did:plc:bob");
143
-
assert_ne!(first.public_key, second.public_key);
164
+
let did = "did:plc:default";
165
+
setup_account(did);
166
+
let (_, _) = ensure_identity(did, &mut OsRng).unwrap();
167
+
let loaded = load_identity_default().unwrap();
168
+
assert_eq!(loaded.did, did);
144
169
});
145
170
}
146
171
147
172
#[test]
148
173
fn load_identity_rejects_garbage_json() {
149
174
with_test_dir(|_| {
150
-
config::ensure_data_dir().unwrap();
151
-
fs::write(file_path(), "not json {{{").unwrap();
152
-
assert!(load_identity().is_err());
175
+
let did = "did:plc:test";
176
+
setup_account(did);
177
+
config::ensure_account_dir(did).unwrap();
178
+
std::fs::write(
179
+
config::account_dir(did).join("identity.json"),
180
+
"not json {{{",
181
+
)
182
+
.unwrap();
183
+
assert!(load_identity(did).is_err());
153
184
});
154
185
}
155
186
156
187
#[test]
157
188
fn load_identity_rejects_valid_json_wrong_schema() {
158
189
with_test_dir(|_| {
159
-
config::ensure_data_dir().unwrap();
160
-
fs::write(file_path(), r#"{"color": "blue"}"#).unwrap();
161
-
assert!(load_identity().is_err());
162
-
});
163
-
}
164
-
165
-
#[test]
166
-
fn load_identity_rejects_empty_file() {
167
-
with_test_dir(|_| {
168
-
config::ensure_data_dir().unwrap();
169
-
fs::write(file_path(), "").unwrap();
170
-
assert!(load_identity().is_err());
171
-
});
172
-
}
173
-
174
-
#[test]
175
-
fn load_identity_rejects_binary_noise() {
176
-
with_test_dir(|_| {
177
-
config::ensure_data_dir().unwrap();
178
-
fs::write(file_path(), vec![0xFF, 0xFE, 0x00, 0x01]).unwrap();
179
-
assert!(load_identity().is_err());
190
+
let did = "did:plc:test";
191
+
setup_account(did);
192
+
config::ensure_account_dir(did).unwrap();
193
+
std::fs::write(
194
+
config::account_dir(did).join("identity.json"),
195
+
r#"{"color": "blue"}"#,
196
+
)
197
+
.unwrap();
198
+
assert!(load_identity(did).is_err());
180
199
});
181
200
}
182
201
···
204
223
fn public_key_bytes_rejects_wrong_length() {
205
224
let identity = Identity {
206
225
did: "did:plc:test".into(),
207
-
public_key: BASE64.encode([0u8; 16]), // 16 bytes, not 32
226
+
public_key: BASE64.encode([0u8; 16]),
208
227
private_key: BASE64.encode([0u8; 32]),
209
228
};
210
229
let err = identity.public_key_bytes().unwrap_err().to_string();
···
216
235
let identity = Identity {
217
236
did: "did:plc:test".into(),
218
237
public_key: BASE64.encode([0u8; 32]),
219
-
private_key: BASE64.encode([0u8; 64]), // 64 bytes, not 32
238
+
private_key: BASE64.encode([0u8; 64]),
220
239
};
221
240
let err = identity.private_key_bytes().unwrap_err().to_string();
222
241
assert!(err.contains("64 bytes"), "expected length in error: {err}");
+1
-1
crates/opake-cli/src/main.rs
+1
-1
crates/opake-cli/src/main.rs
+176
-37
crates/opake-cli/src/session.rs
+176
-37
crates/opake-cli/src/session.rs
···
4
4
use crate::config;
5
5
use crate::transport::ReqwestTransport;
6
6
7
-
const FILENAME: &str = "session.json";
7
+
/// Resolved account context passed to every command.
8
+
#[derive(Debug)]
9
+
#[allow(dead_code)] // pds_url used once commands migrate to CommandContext (#52)
10
+
pub struct CommandContext {
11
+
pub did: String,
12
+
pub pds_url: String,
13
+
}
14
+
15
+
/// Resolve `--as` flag (or default account) to a CommandContext.
16
+
pub fn resolve_context(as_flag: Option<&str>) -> anyhow::Result<CommandContext> {
17
+
let config = config::load_config()?;
18
+
19
+
let did = match as_flag {
20
+
Some(value) if value.starts_with("did:") => value.to_string(),
21
+
Some(handle) => config
22
+
.accounts
23
+
.iter()
24
+
.find(|(_, acc)| acc.handle == handle)
25
+
.map(|(did, _)| did.clone())
26
+
.ok_or_else(|| anyhow::anyhow!("no account with handle {handle}"))?,
27
+
None => config
28
+
.default_did
29
+
.ok_or_else(|| anyhow::anyhow!("no default account: run `opake login` first"))?,
30
+
};
8
31
9
-
fn load_session() -> anyhow::Result<Session> {
10
-
config::load_json(FILENAME)
32
+
let account = config
33
+
.accounts
34
+
.get(&did)
35
+
.ok_or_else(|| anyhow::anyhow!("no account for {did}"))?;
36
+
37
+
Ok(CommandContext {
38
+
did,
39
+
pds_url: account.pds_url.clone(),
40
+
})
11
41
}
12
42
13
-
/// Restore a saved session and build an authenticated XRPC client.
14
-
pub fn load_client() -> anyhow::Result<XrpcClient<ReqwestTransport>> {
43
+
/// Restore a saved session and build an authenticated XRPC client for a specific account.
44
+
pub fn load_client(did: &str) -> anyhow::Result<XrpcClient<ReqwestTransport>> {
15
45
let config = config::load_config()?;
16
-
let session = load_session()?;
46
+
let account = config
47
+
.accounts
48
+
.get(did)
49
+
.ok_or_else(|| anyhow::anyhow!("no account for {did}: run `opake login` first"))?;
50
+
let session: Session = config::load_account_json(did, "session.json")?;
17
51
let transport = ReqwestTransport::new();
18
-
Ok(XrpcClient::with_session(transport, config.pds_url, session))
52
+
Ok(XrpcClient::with_session(
53
+
transport,
54
+
account.pds_url.clone(),
55
+
session,
56
+
))
57
+
}
58
+
59
+
/// Load a client for the default account. Convenience for commands
60
+
/// that haven't been migrated to CommandContext yet.
61
+
pub fn load_client_default() -> anyhow::Result<XrpcClient<ReqwestTransport>> {
62
+
let ctx = resolve_context(None)?;
63
+
load_client(&ctx.did)
19
64
}
20
65
21
66
/// Extract the session if it was refreshed during this client's lifetime.
···
28
73
}
29
74
}
30
75
31
-
/// Persist a refreshed session to disk. Called once from the dispatch layer.
32
-
pub fn persist_session(session: &Session) -> anyhow::Result<()> {
33
-
config::save_json(FILENAME, session)?;
34
-
info!("persisted refreshed session tokens");
76
+
/// Persist a refreshed session to disk for a specific account.
77
+
pub fn persist_session(did: &str, session: &Session) -> anyhow::Result<()> {
78
+
config::save_account_json(did, "session.json", session)?;
79
+
info!("persisted refreshed session tokens for {}", did);
35
80
Ok(())
36
81
}
37
82
···
39
84
mod tests {
40
85
use super::*;
41
86
use crate::utils::test_harness::with_test_dir;
87
+
use std::collections::BTreeMap;
42
88
use std::fs;
43
89
44
90
fn fake_session() -> Session {
···
50
96
}
51
97
}
52
98
53
-
fn file_path() -> std::path::PathBuf {
54
-
config::data_dir().join(FILENAME)
99
+
fn setup_account(did: &str, pds_url: &str, handle: &str) {
100
+
let mut accounts = BTreeMap::new();
101
+
accounts.insert(
102
+
did.to_string(),
103
+
config::AccountConfig {
104
+
pds_url: pds_url.into(),
105
+
handle: handle.into(),
106
+
},
107
+
);
108
+
config::save_config(&config::Config {
109
+
default_did: Some(did.to_string()),
110
+
accounts,
111
+
})
112
+
.unwrap();
55
113
}
56
114
57
115
#[test]
58
116
fn persist_and_load_session_roundtrip() {
59
117
with_test_dir(|_| {
118
+
let did = "did:plc:test123";
119
+
setup_account(did, "https://pds.test", "alice.test");
60
120
let session = fake_session();
61
-
persist_session(&session).unwrap();
121
+
persist_session(did, &session).unwrap();
62
122
63
-
let loaded = load_session().unwrap();
123
+
let loaded: Session = config::load_account_json(did, "session.json").unwrap();
64
124
assert_eq!(loaded.did, session.did);
65
125
assert_eq!(loaded.handle, session.handle);
66
126
assert_eq!(loaded.access_jwt, session.access_jwt);
···
71
131
#[test]
72
132
fn load_client_without_session_errors() {
73
133
with_test_dir(|_| {
74
-
assert!(load_client().is_err());
134
+
assert!(load_client("did:plc:nobody").is_err());
135
+
});
136
+
}
137
+
138
+
#[test]
139
+
fn load_client_default_without_config_errors() {
140
+
with_test_dir(|_| {
141
+
assert!(load_client_default().is_err());
142
+
});
143
+
}
144
+
145
+
#[test]
146
+
fn resolve_context_uses_default_did() {
147
+
with_test_dir(|_| {
148
+
setup_account("did:plc:alice", "https://pds.alice", "alice.test");
149
+
let ctx = resolve_context(None).unwrap();
150
+
assert_eq!(ctx.did, "did:plc:alice");
151
+
assert_eq!(ctx.pds_url, "https://pds.alice");
75
152
});
76
153
}
77
154
78
155
#[test]
79
-
fn load_session_rejects_garbage_json() {
156
+
fn resolve_context_with_did_flag() {
80
157
with_test_dir(|_| {
81
-
config::ensure_data_dir().unwrap();
82
-
fs::write(file_path(), "not json at all {{{").unwrap();
83
-
assert!(load_session().is_err());
158
+
let mut accounts = BTreeMap::new();
159
+
accounts.insert(
160
+
"did:plc:alice".into(),
161
+
config::AccountConfig {
162
+
pds_url: "https://pds.alice".into(),
163
+
handle: "alice.test".into(),
164
+
},
165
+
);
166
+
accounts.insert(
167
+
"did:plc:bob".into(),
168
+
config::AccountConfig {
169
+
pds_url: "https://pds.bob".into(),
170
+
handle: "bob.test".into(),
171
+
},
172
+
);
173
+
config::save_config(&config::Config {
174
+
default_did: Some("did:plc:alice".into()),
175
+
accounts,
176
+
})
177
+
.unwrap();
178
+
179
+
let ctx = resolve_context(Some("did:plc:bob")).unwrap();
180
+
assert_eq!(ctx.did, "did:plc:bob");
181
+
assert_eq!(ctx.pds_url, "https://pds.bob");
84
182
});
85
183
}
86
184
87
185
#[test]
88
-
fn load_session_rejects_valid_json_wrong_schema() {
186
+
fn resolve_context_with_handle_flag() {
89
187
with_test_dir(|_| {
90
-
config::ensure_data_dir().unwrap();
91
-
fs::write(file_path(), r#"{"name": "bob", "age": 42}"#).unwrap();
92
-
assert!(load_session().is_err());
188
+
let mut accounts = BTreeMap::new();
189
+
accounts.insert(
190
+
"did:plc:alice".into(),
191
+
config::AccountConfig {
192
+
pds_url: "https://pds.alice".into(),
193
+
handle: "alice.test".into(),
194
+
},
195
+
);
196
+
accounts.insert(
197
+
"did:plc:bob".into(),
198
+
config::AccountConfig {
199
+
pds_url: "https://pds.bob".into(),
200
+
handle: "bob.test".into(),
201
+
},
202
+
);
203
+
config::save_config(&config::Config {
204
+
default_did: Some("did:plc:alice".into()),
205
+
accounts,
206
+
})
207
+
.unwrap();
208
+
209
+
let ctx = resolve_context(Some("bob.test")).unwrap();
210
+
assert_eq!(ctx.did, "did:plc:bob");
93
211
});
94
212
}
95
213
96
214
#[test]
97
-
fn load_session_rejects_empty_file() {
215
+
fn resolve_context_unknown_handle_errors() {
216
+
with_test_dir(|_| {
217
+
setup_account("did:plc:alice", "https://pds.alice", "alice.test");
218
+
let err = resolve_context(Some("nobody.test")).unwrap_err();
219
+
assert!(err.to_string().contains("nobody.test"));
220
+
});
221
+
}
222
+
223
+
#[test]
224
+
fn resolve_context_no_default_errors() {
98
225
with_test_dir(|_| {
99
-
config::ensure_data_dir().unwrap();
100
-
fs::write(file_path(), "").unwrap();
101
-
assert!(load_session().is_err());
226
+
config::save_config(&config::Config {
227
+
default_did: None,
228
+
accounts: BTreeMap::new(),
229
+
})
230
+
.unwrap();
231
+
let err = resolve_context(None).unwrap_err();
232
+
assert!(err.to_string().contains("opake login"));
102
233
});
103
234
}
104
235
105
236
#[test]
106
-
fn load_session_rejects_binary_noise() {
237
+
fn load_session_rejects_garbage_json() {
107
238
with_test_dir(|_| {
108
-
config::ensure_data_dir().unwrap();
109
-
fs::write(file_path(), vec![0xFF, 0xFE, 0x00, 0x01]).unwrap();
110
-
assert!(load_session().is_err());
239
+
let did = "did:plc:test";
240
+
setup_account(did, "https://pds.test", "test.handle");
241
+
config::ensure_account_dir(did).unwrap();
242
+
fs::write(
243
+
config::account_dir(did).join("session.json"),
244
+
"not json {{{",
245
+
)
246
+
.unwrap();
247
+
assert!(load_client(did).is_err());
111
248
});
112
249
}
113
250
114
251
#[test]
115
-
fn load_session_rejects_partial_session() {
252
+
fn load_session_rejects_valid_json_wrong_schema() {
116
253
with_test_dir(|_| {
117
-
config::ensure_data_dir().unwrap();
254
+
let did = "did:plc:test";
255
+
setup_account(did, "https://pds.test", "test.handle");
256
+
config::ensure_account_dir(did).unwrap();
118
257
fs::write(
119
-
file_path(),
120
-
r#"{"did": "did:plc:x", "handle": "a", "accessJwt": "tok"}"#,
258
+
config::account_dir(did).join("session.json"),
259
+
r#"{"name": "bob", "age": 42}"#,
121
260
)
122
261
.unwrap();
123
-
assert!(load_session().is_err());
262
+
assert!(load_client(did).is_err());
124
263
});
125
264
}
126
265
}
+61
crates/opake-core/src/records.rs
+61
crates/opake-core/src/records.rs
···
143
143
}
144
144
145
145
// ---------------------------------------------------------------------------
146
+
// app.opake.cloud.publicKey
147
+
// ---------------------------------------------------------------------------
148
+
149
+
pub const PUBLIC_KEY_COLLECTION: &str = "app.opake.cloud.publicKey";
150
+
pub const PUBLIC_KEY_RKEY: &str = "self";
151
+
152
+
/// Singleton public key record published on the user's PDS.
153
+
/// Uses rkey "self" (like app.bsky.actor.profile).
154
+
#[derive(Debug, Clone, Serialize, Deserialize)]
155
+
#[serde(rename_all = "camelCase")]
156
+
pub struct PublicKeyRecord {
157
+
#[serde(default = "default_version")]
158
+
pub version: u32,
159
+
pub public_key: AtBytes,
160
+
pub algo: String,
161
+
pub created_at: String,
162
+
}
163
+
164
+
impl PublicKeyRecord {
165
+
pub fn new(public_key_bytes: &[u8], created_at: &str) -> Self {
166
+
use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
167
+
Self {
168
+
version: SCHEMA_VERSION,
169
+
public_key: AtBytes {
170
+
encoded: BASE64.encode(public_key_bytes),
171
+
},
172
+
algo: "x25519".into(),
173
+
created_at: created_at.into(),
174
+
}
175
+
}
176
+
}
177
+
178
+
// ---------------------------------------------------------------------------
146
179
// app.opake.cloud.grant
147
180
// ---------------------------------------------------------------------------
148
181
···
207
240
#[test]
208
241
fn check_version_rejects_max() {
209
242
assert!(check_version(u32::MAX).is_err());
243
+
}
244
+
245
+
#[test]
246
+
fn public_key_record_new_sets_defaults() {
247
+
let record = PublicKeyRecord::new(&[42u8; 32], "2026-03-01T00:00:00Z");
248
+
assert_eq!(record.version, SCHEMA_VERSION);
249
+
assert_eq!(record.algo, "x25519");
250
+
assert_eq!(record.created_at, "2026-03-01T00:00:00Z");
251
+
}
252
+
253
+
#[test]
254
+
fn public_key_record_roundtrips_through_json() {
255
+
let record = PublicKeyRecord::new(&[7u8; 32], "2026-03-01T12:00:00Z");
256
+
let json = serde_json::to_string(&record).unwrap();
257
+
let parsed: PublicKeyRecord = serde_json::from_str(&json).unwrap();
258
+
259
+
assert_eq!(parsed.version, record.version);
260
+
assert_eq!(parsed.public_key.encoded, record.public_key.encoded);
261
+
assert_eq!(parsed.algo, "x25519");
262
+
assert_eq!(parsed.created_at, "2026-03-01T12:00:00Z");
263
+
}
264
+
265
+
#[test]
266
+
fn public_key_record_uses_atbytes_wire_format() {
267
+
let record = PublicKeyRecord::new(&[1u8; 32], "2026-03-01T00:00:00Z");
268
+
let json = serde_json::to_value(&record).unwrap();
269
+
// atproto $bytes convention: { "$bytes": "<base64>" }
270
+
assert!(json["publicKey"]["$bytes"].is_string());
210
271
}
211
272
}
+37
lexicons/app.opake.cloud.publicKey.json
+37
lexicons/app.opake.cloud.publicKey.json
···
1
+
{
2
+
"lexicon": 1,
3
+
"id": "app.opake.cloud.publicKey",
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
+
"defs": {
6
+
"main": {
7
+
"type": "record",
8
+
"key": "literal:self",
9
+
"record": {
10
+
"type": "object",
11
+
"required": ["version", "publicKey", "algo", "createdAt"],
12
+
"properties": {
13
+
"version": {
14
+
"type": "integer",
15
+
"minimum": 1,
16
+
"description": "Schema version for forward compatibility."
17
+
},
18
+
"publicKey": {
19
+
"type": "bytes",
20
+
"maxLength": 32,
21
+
"description": "Raw X25519 public key bytes."
22
+
},
23
+
"algo": {
24
+
"type": "string",
25
+
"knownValues": ["x25519"],
26
+
"description": "Key algorithm identifier."
27
+
},
28
+
"createdAt": {
29
+
"type": "string",
30
+
"format": "datetime",
31
+
"description": "When the key was created."
32
+
}
33
+
}
34
+
}
35
+
}
36
+
}
37
+
}
History
1 round
0 comments
sans-self.org
submitted
#0
2 commits
expand
collapse
Add publicKey lexicon and PublicKeyRecord struct
New singleton record type (app.opake.cloud.publicKey, rkey: "self")
for publishing X25519 encryption public keys on a user's PDS.
Uses AtBytes for wire-compatible $bytes serialization.
Add multi-account config and per-account storage
Config now tracks multiple accounts with default_did and a
BTreeMap of AccountConfig entries. Session and identity files
move to per-account directories under accounts/<sanitized-did>/.
Adds CommandContext and resolve_context() for --as flag resolution.
Existing commands use _default convenience functions until they
migrate to CommandContext in the next step.
1/1 success
expand
collapse
expand 0 comments
pull request successfully merged