OXYGENHUB

spectra connect: trust a server through the owner's delegation, pin it, claim it (#21) eca5af92279b

A workstation trusts a Spectra elsewhere only when the owner's identity
delegates to its instance key and that key vouches for its certificate.
The claim carries the named identity and the rotation to the owner's
current one, since delegating to the instance changes the owner's KeyId.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key bc27dd96080c
When
· 2026-11-03 13:29 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
eca5af92279b8382fc9aebe2997e19494c1c7b58a1855af8eef9eb75ca4cf8cf
Depends on
09752034b5a8 258bb3824813 36537112fcac 571572f5fb2c 77df591d5a2f 8dc6df8c4d4e b576d05cf862 d17cbc7069ce eb675dfa5e18
Depended on by
c7161ec1a607 b044d90930d9 98a0fb9f0948 67bfa06a894f
In bookmarks
main
In tags
v0.1.0
Edits
28 atoms, 2 tree operations, 10 files

Browse the tree at this change

added src/cli/connect.rs +191 −0
1 +
//! `spectra connect` and the client configuration (spec §8.6): trust a
2 +
//! Spectra on another machine because the owner's own identity delegates
3 +
//! to its instance key and that key vouches for its certificate; pin the
4 +
//! certificate; claim a server set up with `spectra init --owner`.
5 +
6 +
use super::{identity, ui, Ctx};
7 +
use anyhow::{bail, Context, Result};
8 +
use oxy_core::identity::Identity;
9 +
use serde_json::Value as Json;
10 +
use spectra_core::daemon::WireEvent;
11 +
use spectra_core::keys::KeyPair;
12 +
use spectra_core::principals::{identity_payload, signed_event, DELEGATION_SCOPE};
13 +
use spectra_mcp::client::{host_port, Client, Endpoint};
14 +
use spectra_mcp::tls::Pinned;
15 +
use std::path::{Path, PathBuf};
16 +
17 +
/// The client configuration: which server, and what to trust there.
18 +
#[derive(Clone, Debug)]
19 +
pub struct ClientCfg {
20 +
    pub server: String,
21 +
    pub instance: String,
22 +
    pub tls_spki: [u8; 32],
23 +
}
24 +
25 +
impl ClientCfg {
26 +
    pub fn endpoint(&self) -> Result<Endpoint> {
27 +
        Endpoint::tls(&self.server, self.tls_spki)
28 +
    }
29 +
}
30 +
31 +
/// `SPECTRA_SERVER`, else `$XDG_CONFIG_HOME/spectra/client.toml`, else
32 +
/// `~/.config/spectra/client.toml`.
33 +
pub fn path() -> PathBuf {
34 +
    if let Some(p) = std::env::var_os("SPECTRA_SERVER").filter(|p| !p.is_empty()) {
35 +
        return PathBuf::from(p);
36 +
    }
37 +
    config_dir().join("client.toml")
38 +
}
39 +
40 +
fn config_dir() -> PathBuf {
41 +
    let base = std::env::var_os("XDG_CONFIG_HOME")
42 +
        .filter(|p| !p.is_empty())
43 +
        .map(PathBuf::from)
44 +
        .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))
45 +
        .unwrap_or_else(|| PathBuf::from("."));
46 +
    base.join("spectra")
47 +
}
48 +
49 +
/// The client configuration, if this machine uses a Spectra elsewhere.
50 +
pub fn load() -> Result<Option<ClientCfg>> {
51 +
    let p = path();
52 +
    let text = match std::fs::read_to_string(&p) {
53 +
        Ok(t) => t,
54 +
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
55 +
        Err(e) => return Err(e).with_context(|| format!("reading {}", p.display())),
56 +
    };
57 +
    let t: toml::Table = text.parse().with_context(|| format!("reading {}", p.display()))?;
58 +
    let get = |k: &str| t.get(k).and_then(|v| v.as_str()).map(String::from).with_context(|| format!("{} has no {k}", p.display()));
59 +
    let spki = hex::decode(get("tls_spki")?).ok().and_then(|b| b.try_into().ok()).with_context(|| format!("{}: tls_spki", p.display()))?;
60 +
    Ok(Some(ClientCfg { server: get("server")?, instance: get("instance")?, tls_spki: spki }))
61 +
}
62 +
63 +
fn save(cfg: &ClientCfg) -> Result<PathBuf> {
64 +
    let p = path();
65 +
    if let Some(dir) = p.parent() {
66 +
        std::fs::create_dir_all(dir)?;
67 +
    }
68 +
    std::fs::write(
69 +
        &p,
70 +
        format!(
71 +
            "# Spectra on another machine (spec §8.6), written by `spectra connect`.\n\nserver = \"{}\"\ninstance = \"{}\"                    # the instance key, delegated by the owner\ntls_spki = \"{}\"   # the certificate the instance key vouches for\n",
72 +
            cfg.server,
73 +
            cfg.instance,
74 +
            hex::encode(cfg.tls_spki)
75 +
        ),
76 +
    )?;
77 +
    Ok(p)
78 +
}
79 +
80 +
/// The key pair of the identity `key` in the owner's home, if it is theirs.
81 +
fn own(home: &Path, key: &str) -> Option<KeyPair> {
82 +
    let keys = home.join("keys");
83 +
    KeyPair::load(&keys.join(format!("{key}.secret")), &keys.join(format!("{key}.identity"))).ok()
84 +
}
85 +
86 +
pub async fn connect(_ctx: &Ctx, url: &str, owner_home: Option<PathBuf>) -> Result<i32> {
87 +
    let server = format!("https://{}", host_port(url)?);
88 +
    let home = owner_home.unwrap_or_else(oxy_core::repo::default_home);
89 +
    let owner = KeyPair::load_home(&home)
90 +
        .with_context(|| format!("the owner connects with their own identity: none usable in {} (or --owner-home)", home.display()))?;
91 +
92 +
    // Who is there, before trusting anything.
93 +
    let probe = Pinned::new(None);
94 +
    let anon = Client::at(Endpoint::Tls { addr: host_port(url)?, config: probe.client_config()? }, None);
95 +
    let r = anon.send("GET", "/api/v1/instance", &[], vec![]).await.with_context(|| format!("reaching {server}"))?;
96 +
    if r.status != 200 {
97 +
        bail!("{server} answered {} to /api/v1/instance: is it a Spectra?", r.status);
98 +
    }
99 +
    let answer = r.json()?;
100 +
    let (_, seen) = probe.seen().context("the server showed no certificate")?;
101 +
    let checked = spectra_mcp::instance::check(&answer, &seen)?;
102 +
    let instance_key = checked.instance.hash();
103 +
104 +
    // Whose it is, or whose it is waiting for.
105 +
    let recorded = checked.owner.clone();
106 +
    let named = answer.get("named_owner").and_then(Json::as_str).map(String::from);
107 +
    match (&recorded, &named) {
108 +
        (Some(k), _) if *k != owner.key.to_hex() && own(&home, k).is_none() => {
109 +
            bail!("{server} belongs to someone else (owner {})", &k[..12.min(k.len())]);
110 +
        }
111 +
        (None, Some(k)) if own(&home, k).is_none() => {
112 +
            bail!("{server} was set up for {}, which is not an identity in {}", &k[..12.min(k.len())], home.display());
113 +
        }
114 +
        (None, None) => bail!("{server} has no owner and names none"),
115 +
        _ => {}
116 +
    }
117 +
118 +
    // Trust: the owner's own identity delegates to that instance key now.
119 +
    if !owner.identity.delegates_to(&instance_key, DELEGATION_SCOPE, spectra_core::ids::now_ms()) {
120 +
        let dir = config_dir();
121 +
        std::fs::create_dir_all(&dir)?;
122 +
        let file = dir.join(format!("{}-instance.identity", safe(&checked.name)));
123 +
        std::fs::write(&file, checked.instance.encode())?;
124 +
        println!("{} {} {}", ui::caution(), ui::subject(&server), ui::dim("is not yet yours to trust"));
125 +
        println!("{}", ui::kv("instance", format!("{}{}{}", checked.name, ui::sep(), ui::hash(instance_key.short(12)))));
126 +
        println!("  {} {}", ui::note(), ui::dim("your identity does not delegate to its instance key; if this is your server, run:"));
127 +
        println!("    {}", ui::id(format!("oxy identity delegate add {} --scope changes", file.display())));
128 +
        println!("  {} {}", ui::note(), ui::dim("then run spectra connect again"));
129 +
        return Ok(1);
130 +
    }
131 +
132 +
    let cfg = ClientCfg { server: server.clone(), instance: instance_key.to_hex(), tls_spki: seen };
133 +
    let saved = save(&cfg)?;
134 +
    let pinned = cfg.endpoint()?;
135 +
136 +
    // Claim it, or bring it up to the owner's current identity.
137 +
    let mut claimed = false;
138 +
    match (&recorded, &named) {
139 +
        (None, Some(k)) => {
140 +
            let named_pair = own(&home, k).expect("checked above");
141 +
            let mut events =
142 +
                vec![WireEvent::from_new(&signed_event(&named_pair, "human", "identity.owner", identity_payload(&named_pair.identity)))];
143 +
            if let Some(e) = identity::rotation(&named_pair.identity, &home)? {
144 +
                events.push(WireEvent::from_new(&e));
145 +
            }
146 +
            let r = Client::at(pinned.clone(), None).send("POST", "/api/v1/claim", &[], serde_json::to_vec(&events)?).await?;
147 +
            if r.status != 200 {
148 +
                bail!("{server} refused the claim: {}", problem(&r.body));
149 +
            }
150 +
            claimed = true;
151 +
        }
152 +
        (Some(k), _) if *k != owner.key.to_hex() => {
153 +
            let rec_pair = own(&home, k).expect("checked above");
154 +
            let rec_id: Identity = rec_pair.identity.clone();
155 +
            if let Some(e) = identity::rotation(&rec_id, &home)? {
156 +
                let body = serde_json::to_vec(&vec![WireEvent::from_new(&e)])?;
157 +
                let r = Client::at(pinned.clone(), Some(rec_pair)).send("POST", "/api/v1/ledger/events", &[], body).await?;
158 +
                if r.status != 200 {
159 +
                    bail!("{server} refused the owner's current identity: {}", problem(&r.body));
160 +
                }
161 +
            }
162 +
        }
163 +
        _ => {}
164 +
    }
165 +
166 +
    println!("{} {}  {}", ui::done(if claimed { "Claimed" } else { "Connected" }), ui::subject(&checked.name), ui::path(&server));
167 +
    println!("{}", ui::kv("owner", format!("{}{}{}", ui::author(&owner.identity.name), ui::sep(), ui::hash(owner.key.short(12)))));
168 +
    println!("{}", ui::kv("instance", format!("{}{}", ui::hash(instance_key.short(12)), ui::dim(" (delegated by you)"))));
169 +
    println!(
170 +
        "{}",
171 +
        ui::kv("certificate", format!("{}{}", ui::hash(hex::encode(&seen[..8])), ui::dim(" (pinned; vouched for by the instance key)")))
172 +
    );
173 +
    println!("{}", ui::kv("config", ui::path(saved.display())));
174 +
    Ok(0)
175 +
}
176 +
177 +
fn safe(name: &str) -> String {
178 +
    let s: String = name.chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' { c } else { '_' }).collect();
179 +
    if s.is_empty() {
180 +
        "spectra".into()
181 +
    } else {
182 +
        s
183 +
    }
184 +
}
185 +
186 +
fn problem(body: &[u8]) -> String {
187 +
    serde_json::from_slice::<Json>(body)
188 +
        .ok()
189 +
        .and_then(|v| v.get("detail").or_else(|| v.get("title")).and_then(Json::as_str).map(String::from))
190 +
        .unwrap_or_else(|| String::from_utf8_lossy(body).into_owned())
191 +
}
added tests/connect.rs +97 −0
1 +
//! `spectra connect` (spec §8.6, spectra#21): a workstation trusts a server
2 +
//! because the owner delegates to its instance key and that key vouches for
3 +
//! its certificate; it claims a server set up with `init --owner`, even
4 +
//! though delegating changed the owner's KeyId; a new certificate is
5 +
//! refused until connect runs again; someone else's server is refused.
6 +
7 +
mod common;
8 +
9 +
use common::{delegate, hermetic, run, text, Serving};
10 +
use spectra_core::keys::KeyPair;
11 +
use spectra_mcp::client::{Client, Endpoint};
12 +
use spectra_mcp::tls::Pinned;
13 +
use std::path::Path;
14 +
15 +
/// A server in `s` set up for the owner whose home is `owner_home`.
16 +
fn server_for(s: &Path, owner_home: &Path) -> (Serving, u16) {
17 +
    let owner = KeyPair::load_home(owner_home).unwrap();
18 +
    let o = run(s, &["init", "--owner", &owner.key.to_hex(), "--name", "earth"]);
19 +
    assert!(o.status.success(), "{}", text(&o));
20 +
    Serving::start_tls(s)
21 +
}
22 +
23 +
async fn instance_owner(port: u16) -> serde_json::Value {
24 +
    let p = Pinned::new(None);
25 +
    let c = Client::at(Endpoint::Tls { addr: format!("127.0.0.1:{port}"), config: p.client_config().unwrap() }, None);
26 +
    c.send("GET", "/api/v1/instance", &[], vec![]).await.unwrap().json().unwrap()["owner"].clone()
27 +
}
28 +
29 +
#[tokio::test]
30 +
async fn the_owner_connects_delegates_and_claims() {
31 +
    let (sd, cd) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
32 +
    let (s, c) = (sd.path(), cd.path());
33 +
    KeyPair::create_home(&c.join("owner"), "Andy McCall", "").unwrap();
34 +
    let (srv, port) = server_for(s, &c.join("owner"));
35 +
    let url = format!("https://127.0.0.1:{port}");
36 +
37 +
    // First contact: not yet delegated, so not trusted; told what to run.
38 +
    let o = run(c, &["connect", &url]);
39 +
    assert_eq!(o.status.code(), Some(1), "{}", text(&o));
40 +
    assert!(text(&o).contains("oxy identity delegate add"), "{}", text(&o));
41 +
    let saved = c.join(".config/spectra/earth-instance.identity");
42 +
    assert!(saved.exists(), "the instance's public identity is saved to delegate to");
43 +
    assert!(!c.join(".config/spectra/client.toml").exists(), "nothing trusted yet");
44 +
45 +
    // The owner delegates (which gives their identity a new KeyId) ...
46 +
    let instance = KeyPair::instance(&s.join("data"), "earth").unwrap();
47 +
    assert_eq!(oxy_core::identity::Identity::decode(&std::fs::read(&saved).unwrap()).unwrap().hash(), instance.key);
48 +
    delegate(&c.join("owner"), &instance);
49 +
    let current = KeyPair::load_home(&c.join("owner")).unwrap();
50 +
51 +
    // ... and connects again: trusted, pinned, and claimed as theirs.
52 +
    let o = run(c, &["connect", &url]);
53 +
    assert!(o.status.success(), "{}", text(&o));
54 +
    assert!(text(&o).contains("Claimed"), "{}", text(&o));
55 +
    let cfg = std::fs::read_to_string(c.join(".config/spectra/client.toml")).unwrap();
56 +
    assert!(cfg.contains(&instance.key.to_hex()) && cfg.contains("tls_spki"), "{cfg}");
57 +
    assert_eq!(instance_owner(port).await, current.key.to_hex(), "recorded at the owner's current identity");
58 +
59 +
    // Again: nothing to claim, still trusted.
60 +
    let o = run(c, &["connect", &url]);
61 +
    assert!(o.status.success(), "{}", text(&o));
62 +
    assert!(text(&o).contains("Connected"), "{}", text(&o));
63 +
    srv.stop();
64 +
65 +
    // A new certificate (the old one lost) is refused on the old pin ...
66 +
    std::fs::remove_file(s.join("data/keys/tls.crt")).unwrap();
67 +
    std::fs::remove_file(s.join("data/keys/tls.key")).unwrap();
68 +
    let (srv, port) = Serving::start_tls(s);
69 +
    let url = format!("https://127.0.0.1:{port}");
70 +
    let cfg = std::fs::read_to_string(c.join(".config/spectra/client.toml")).unwrap();
71 +
    let old_pin: [u8; 32] =
72 +
        hex::decode(cfg.lines().find(|l| l.starts_with("tls_spki")).unwrap().split('"').nth(1).unwrap()).unwrap().try_into().unwrap();
73 +
    let pinned = Client::at(Endpoint::tls(&url, old_pin).unwrap(), None);
74 +
    assert!(pinned.send("GET", "/api/v1/instance", &[], vec![]).await.is_err());
75 +
    // ... until connect checks that the same instance key vouches for it.
76 +
    let o = run(c, &["connect", &url]);
77 +
    assert!(o.status.success(), "{}", text(&o));
78 +
    let cfg2 = std::fs::read_to_string(c.join(".config/spectra/client.toml")).unwrap();
79 +
    assert_ne!(cfg, cfg2, "re-pinned");
80 +
    srv.stop();
81 +
82 +
    let o = hermetic(s).args(["admin", "verify"]).output().unwrap();
83 +
    assert!(o.status.success(), "{}", text(&o));
84 +
}
85 +
86 +
#[test]
87 +
fn someone_elses_server_is_refused() {
88 +
    let (sd, cd, md) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
89 +
    let (s, c, m) = (sd.path(), cd.path(), md.path());
90 +
    KeyPair::create_home(&c.join("owner"), "Andy McCall", "").unwrap();
91 +
    KeyPair::create_home(&m.join("owner"), "Mallory", "").unwrap();
92 +
    let (srv, port) = server_for(s, &m.join("owner"));
93 +
    let o = run(c, &["connect", &format!("https://127.0.0.1:{port}")]);
94 +
    assert!(!o.status.success());
95 +
    assert!(text(&o).contains("was set up for"), "{}", text(&o));
96 +
    srv.stop();
97 +
}
modified CHANGELOG.md +1 −0
…
1 unchanged lines
2 2
3 3
## Unreleased
4 4
5 +
- **Added: `spectra connect https://HOST[:PORT]` (#21).** Uses a Spectra on another machine. It trusts the server only when your own identity delegates to its instance key and that key vouches for the certificate it presents; until you have delegated, it saves the instance's public identity and prints the `oxy identity delegate add` to run. Then it pins the certificate in `~/.config/spectra/client.toml` (or `SPECTRA_SERVER`), claims a server set up with `spectra init --owner`, and brings the server up to your current identity. A certificate that changes is refused until `connect` checks it again. The stdio shim uses the configured server (with `--home`).
5 6
- **Added: setting a server up without the owner's key (#22).** `spectra init --owner KEYID` makes the instance key and the ledger with only the owner's KeyId named. Until that owner claims it, the daemon answers who it is and refuses everything else, saying it has no owner yet; the claim is the owner's identity signed by itself, and is accepted from no other key. A signed request the API refuses is now told why.
6 7
- **Added: serving other machines over TLS (#20).** With `[server] bind` beyond loopback (or `spectra serve --bind ADDR`), the daemon serves HTTPS with a certificate of its own, made on first start and kept in `keys/tls.{crt,key}`, or with `[server] tls = { cert, key }`. No certificate authority is involved: the instance key vouches for the certificate with a signed `instance.tls` event, and `GET /api/v1/instance` tells a client, before it trusts anything, who the instance is, whose it is and which certificate it signed. Clients pin that one certificate and check the server's handshake signature. An agent bound to a Unix user is refused over TCP and recorded as `guard.impersonation`, since a network connection has no uid to check.
7 8
- **Added: the README's quick start, a systemd user unit (`packaging/spectra.service`) and `tests/bench.rs`** for the targets of spec §24 (brief 2.7 ms, say 3.1 ms, wait wakes 4.2 ms after a mention, with 1,000 messages, in release).
…
14 unchanged lines
modified crates/spectra-core/src/daemon.rs +25 −13
…
201 unchanged lines
202 202
    }
203 203
204 204
    /// The owner claims a Spectra set up with `spectra init --owner`
205 -
    /// (spec §8.6): their identity, signed by itself over its payload, and
206 -
    /// accepted only when no owner is recorded and the key is the one named.
207 -
    pub async fn claim(&self, w: WireEvent) -> Result<u64> {
208 -
        let n = w.into_new_event()?;
209 -
        if n.kind != "identity.owner" || n.actor != "human" {
210 -
            bail!("a claim is the owner's identity.owner");
211 -
        }
205 +
    /// (spec §8.6): the named identity, signed by itself over its payload,
206 +
    /// then any successors up to the owner's current identity, each a
207 +
    /// rotation as `identity refresh` makes. Accepted only while no owner
208 +
    /// is recorded, and only from the key init named.
209 +
    pub async fn claim(&self, events: Vec<WireEvent>) -> Result<Vec<u64>> {
212 210
        let mut principals = self.principals.read().await.clone();
213 211
        if principals.owner().is_some() {
214 212
            bail!("this Spectra already has an owner");
215 213
        }
216 214
        let named = self.named_owner().context("this Spectra names no owner (spectra init --owner)")?;
217 -
        let key = n.payload.get("key").and_then(|k| k.as_str()).unwrap_or("").to_ascii_lowercase();
218 -
        if key != named || n.auth.key.map(hex::encode) != Some(named.clone()) {
219 -
            bail!("this Spectra was set up for another owner");
215 +
        let mut out = Vec::new();
216 +
        for (i, w) in events.into_iter().enumerate() {
217 +
            let n = w.into_new_event()?;
218 +
            let want = if i == 0 { "identity.owner" } else { "identity.rotated" };
219 +
            if n.kind != want || n.actor != "human" {
220 +
                bail!("a claim is the owner's identity.owner, then their rotations");
221 +
            }
222 +
            if i == 0 {
223 +
                let key = n.payload.get("key").and_then(|k| k.as_str()).unwrap_or("").to_ascii_lowercase();
224 +
                if key != named || n.auth.key.map(hex::encode) != Some(named.clone()) {
225 +
                    bail!("this Spectra was set up for another owner");
226 +
                }
227 +
            }
228 +
            principals.check(&probe_event(&n)).map_err(|why| anyhow::anyhow!("refused the claim: {why}"))?;
229 +
            out.push(n);
230 +
        }
231 +
        if out.is_empty() {
232 +
            bail!("an empty claim");
220 233
        }
221 -
        principals.check(&probe_event(&n)).map_err(|why| anyhow::anyhow!("refused the claim: {why}"))?;
222 -
        let appended = self.ledger.append(vec![n]).await?;
234 +
        let appended = self.ledger.append(out).await?;
223 235
        let _ = self.ledger.checkpoint().await;
224 236
        *self.principals.write().await = Principals::load(&self.ledger).await?;
225 -
        Ok(appended.first().map(|e| e.seq).unwrap_or(0))
237 +
        Ok(appended.iter().map(|e| e.seq).collect())
226 238
    }
227 239
228 240
    /// Write a checkpoint covering everything (at shutdown).
…
75 unchanged lines
modified crates/spectra-mcp/src/http.rs +7 −4
…
72 unchanged lines
73 73
        let p = state.daemon.principals.read().await;
74 74
        (p.instance().cloned(), p.owner().map(|o| o.hash().to_hex()))
75 75
    };
76 +
    let owner_missing = owner.is_none();
76 77
    let Some(instance) = instance else {
77 78
        return (StatusCode::SERVICE_UNAVAILABLE, axum::Json(json!({ "title": "this Spectra has no instance key" }))).into_response();
78 79
    };
…
5 unchanged lines
84 85
        "name": instance.name,
85 86
        "instance": spectra_core::principals::identity_payload(&instance),
86 87
        "owner": owner,
88 +
        // Until claimed: the KeyId `spectra init --owner` named.
89 +
        "named_owner": if owner_missing { state.daemon.named_owner() } else { None },
87 90
        "tls": tls,
88 91
    }))
89 92
    .into_response()
…
3 unchanged lines
93 96
/// their identity (spec §8.6). The event proves itself: it is signed by
94 97
/// the identity it records, over its payload.
95 98
async fn claim(State(state): State<AppState>, body: Bytes) -> Response {
96 -
    let w: WireEvent = match serde_json::from_slice(&body) {
99 +
    let events: Vec<WireEvent> = match serde_json::from_slice(&body) {
97 100
        Ok(v) => v,
98 -
        Err(e) => return (StatusCode::BAD_REQUEST, axum::Json(json!({ "title": format!("not an event: {e}") }))).into_response(),
101 +
        Err(e) => return (StatusCode::BAD_REQUEST, axum::Json(json!({ "title": format!("not a list of events: {e}") }))).into_response(),
99 102
    };
100 -
    match state.daemon.claim(w).await {
101 -
        Ok(seq) => axum::Json(json!({ "seq": seq })).into_response(),
103 +
    match state.daemon.claim(events).await {
104 +
        Ok(seqs) => axum::Json(json!({ "seqs": seqs })).into_response(),
102 105
        Err(e) => (StatusCode::FORBIDDEN, axum::Json(json!({ "title": format!("{e:#}") }))).into_response(),
103 106
    }
104 107
}
…
158 unchanged lines
modified docs/spec.md +1 −1
…
349 unchanged lines
350 350
351 351
Every later connection, from the CLI or a shim, accepts exactly that certificate. A changed certificate is refused until `spectra connect` runs again and the new one is signed by the same delegated instance key. `SPECTRA_SERVER` names a client configuration file for one command.
352 352
353 -
**Remote setup.** `spectra init --owner KEYID` on the server makes the instance key and the ledger without the owner's secret key: the ledger starts with `identity.instance`, and the daemon serves `/api/v1/instance` and nothing else until the owner claims it. The first `spectra connect` from the owner sends `identity.owner`, signed over its payload as every CLI event is (§9.5), and the daemon accepts it only from the key `--owner` named. Until then every other request is refused with the reason "this Spectra has no owner yet".
353 +
**Remote setup.** `spectra init --owner KEYID` on the server makes the instance key and the ledger without the owner's secret key: the ledger starts with `identity.instance`, and the daemon serves `/api/v1/instance` and nothing else until the owner claims it. The first `spectra connect` from the owner claims it: `identity.owner` for the identity `--owner` named, signed by it over its payload as every CLI event is (§9.5), followed by the `identity.rotated` that brings it to the owner's current identity (delegating to the instance key makes a new one). The daemon accepts the claim only while it has no owner and only for the named key, and `/api/v1/instance` says which key it waits for. Until then every other request is refused with the reason "this Spectra has no owner yet".
354 354
355 355
**A client has no ledger file.** With a server configured, every command goes through the JSON API (§18): reads, the owner's signed events (§8.5, "One writer"), `spectra open` and `spectra admin verify` (which the daemon runs and answers). `spectra admin check` and `rebuild` need the lock and run only on the server. A shim needs only the agent's home (`--home`) and the client configuration.
356 356
…
671 unchanged lines
modified src/cli/identity.rs +7 −0
…
133 unchanged lines
134 134
pub async fn refresh_event(ledger: &Ledger, home: &Path) -> Result<Option<spectra_core::event::NewEvent>> {
135 135
    let p = Principals::load(ledger).await?;
136 136
    let recorded = p.owner().context("no owner is recorded (spectra init)")?.clone();
137 +
    rotation(&recorded, home)
138 +
}
139 +
140 +
/// The `identity.rotated` that takes `recorded` to the owner's current
141 +
/// identity in `home`, with the identities between, or none if it is
142 +
/// current.
143 +
pub fn rotation(recorded: &Identity, home: &Path) -> Result<Option<spectra_core::event::NewEvent>> {
137 144
    let pair = KeyPair::load_home(home)?;
138 145
    if pair.key == recorded.hash() {
139 146
        return Ok(None);
…
89 unchanged lines
modified src/cli/mcp.rs +4 −1
…
21 unchanged lines
22 22
pub async fn run(ctx: &Ctx, agent: &str, home: Option<PathBuf>) -> Result<i32> {
23 23
    let home = match home {
24 24
        Some(h) => h,
25 +
        None if ctx.remote()?.is_some() => {
26 +
            bail!("with Spectra on another machine, name the agent's home: spectra mcp --agent {agent} --home DIR")
27 +
        }
25 28
        None => {
26 29
            let ledger = Ledger::open_read_only(&ctx.db()?).await?;
27 30
            let h: Option<String> =
…
5 unchanged lines
33 36
    if key.identity.name != agent {
34 37
        bail!("{} holds {}'s key, not {agent}'s", home.display(), key.identity.name);
35 38
    }
36 -
    let client = Arc::new(Client::new(&ctx.socket(), Some(key)));
39 +
    let client = Arc::new(Client::at(ctx.endpoint()?, Some(key)));
37 40
    let version = Arc::new(Mutex::new(STDIO_VERSIONS[0].to_string()));
38 41
    let out = Arc::new(Mutex::new(tokio::io::stdout()));
39 42
    let mut lines = BufReader::new(tokio::io::stdin()).lines();
…
54 unchanged lines
modified src/cli/mod.rs +24 −0
…
3 unchanged lines
4 4
5 5
pub mod admin;
6 6
pub mod agent;
7 +
pub mod connect;
7 8
pub mod format;
8 9
pub mod human;
9 10
pub mod identity;
…
45 unchanged lines
55 56
        #[arg(long, value_name = "KEYID", conflicts_with = "owner_home")]
56 57
        owner: Option<String>,
57 58
    },
59 +
    /// Use a Spectra on another machine: trust it through your delegation to
60 +
    /// its instance key, pin its certificate, and claim it if it is new
61 +
    Connect {
62 +
        /// The server, https://HOST[:PORT]
63 +
        url: String,
64 +
        /// The owner's OXY_HOME (default: $OXY_HOME, else ~/.config/oxy)
65 +
        #[arg(long)]
66 +
        owner_home: Option<PathBuf>,
67 +
    },
58 68
    /// The owner's and the instance's identities: show, refresh
59 69
    Identity {
60 70
        #[command(subcommand)]
…
216 unchanged lines
277 287
    /// The daemon's Unix socket.
278 288
    pub fn socket(&self) -> PathBuf {
279 289
        self.data.join("spectra.sock")
290 +
    }
291 +
292 +
    /// The Spectra on another machine this one uses, if any (spec §8.6).
293 +
    pub fn remote(&self) -> Result<Option<connect::ClientCfg>> {
294 +
        connect::load()
295 +
    }
296 +
297 +
    /// Where the daemon is: the configured server, else the local socket.
298 +
    pub fn endpoint(&self) -> Result<spectra_mcp::client::Endpoint> {
299 +
        Ok(match self.remote()? {
300 +
            Some(c) => c.endpoint()?,
301 +
            None => spectra_mcp::client::Endpoint::Unix(self.socket()),
302 +
        })
280 303
    }
281 304
282 305
    /// Somewhere to append the owner's signed events: straight to the
…
77 unchanged lines
360 383
    let ctx = Ctx { data: cli.data.unwrap_or_else(default_data) };
361 384
    match cli.command {
362 385
        Command::Version { json } => version(json),
386 +
        Command::Connect { url, owner_home } => runtime()?.block_on(connect::connect(&ctx, &url, owner_home)),
363 387
        Command::Init { owner_home, name, owner: Some(key) } => {
364 388
            let _ = owner_home;
365 389
            runtime()?.block_on(identity::init_for(&ctx, &key, name))
…
63 unchanged lines
modified tests/remote.rs +3 −2
…
12 unchanged lines
13 13
14 14
fn claim_body(pair: &KeyPair) -> Vec<u8> {
15 15
    let e = signed_event(pair, "human", "identity.owner", identity_payload(&pair.identity));
16 -
    serde_json::to_vec(&WireEvent::from_new(&e)).unwrap()
16 +
    serde_json::to_vec(&vec![WireEvent::from_new(&e)]).unwrap()
17 17
}
18 18
19 19
#[tokio::test]
…
13 unchanged lines
33 33
    let info = anon.send("GET", "/api/v1/instance", &[], vec![]).await.unwrap().json().unwrap();
34 34
    assert_eq!(info["name"], "earth");
35 35
    assert!(info["owner"].is_null(), "unclaimed");
36 +
    assert_eq!(info["named_owner"], owner.key.to_hex(), "says whose it waits for");
36 37
    let pin = probe.seen().unwrap().1;
37 38
    let url = format!("https://127.0.0.1:{port}");
38 39
…
12 unchanged lines
51 52
    // Nor can a claim naming the owner but signed by another key.
52 53
    let mut forged = signed_event(&mallory, "human", "identity.owner", identity_payload(&owner.identity));
53 54
    forged.payload = identity_payload(&owner.identity);
54 -
    let r = anon.send("POST", "/api/v1/claim", &[], serde_json::to_vec(&WireEvent::from_new(&forged)).unwrap()).await.unwrap();
55 +
    let r = anon.send("POST", "/api/v1/claim", &[], serde_json::to_vec(&vec![WireEvent::from_new(&forged)]).unwrap()).await.unwrap();
55 56
    assert_eq!(r.status, 403);
56 57
57 58
    // The owner can, once.
…
25 unchanged lines