From 6d8a18610ccd8a612df059dfdfea92256fe7fc25 Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Tue, 15 Sep 2026 13:38:17 -0400 Subject: [PATCH] refactor(cli)!: fold didbot-claim into didbot-operator and didbot-authstore The claim's checks, record, scope and write now live under `didbot_operator::operate`, the one program that runs them, and its session file store is `didbot-authstore`, for any command that signs in as an atproto account. `didbot-operator` links `didbot-scope` and the crates the policy page shares, and no longer links `didbot-serve` or `didbot-pds`. The operator's session file moves from `didbot-claim/sessions.json` to `didbot-operator/sessions.json` under the config directory, so the first run after upgrading signs in again. Co-Authored-By: Claude Fable 5.1 Change-Id: Id1185ff58219017488c589026acad4e169907009 --- Cargo.lock | 47 +++++----- Cargo.toml | 2 +- crates/didbot-agentd/src/sessions.rs | 2 +- crates/didbot-authstore/Cargo.toml | 21 +++++ .../src/lib.rs} | 39 +++++---- crates/didbot-claim/Cargo.toml | 33 ------- crates/didbot-claim/src/lib.rs | 79 ----------------- crates/didbot-identity/src/did.rs | 2 +- crates/didbot-operator/Cargo.toml | 17 +++- .../src/bin/didbot-operator.rs | 8 +- crates/didbot-operator/src/operate.rs | 85 ++++++++++++++++--- .../src/operate}/describe.rs | 6 +- .../src/operate}/identify.rs | 2 +- .../src/operate}/orchestrate.rs | 18 ++-- .../src/operate}/preflight.rs | 0 .../src/operate}/record.rs | 20 ++--- .../src/operate}/scope.rs | 24 +++--- .../src/operate}/tls.rs | 10 +-- .../src/operate}/write.rs | 20 ++--- crates/didbot-policy-check/Cargo.toml | 2 +- crates/didbot-policy-check/tests/claim.rs | 8 +- crates/didbot/Cargo.toml | 2 +- crates/didbot/tests/handshake.rs | 37 ++++---- crates/didbot/tests/reservation.rs | 45 +++++----- deny.toml | 2 +- plan/adversarial.md | 2 +- plan/credentials.md | 2 +- plan/dedupe-audit.md | 2 +- plan/handshake.md | 17 ++-- plan/node.md | 4 +- plan/oauth.md | 7 +- plan/onboarding-policies.md | 2 +- plan/ownership.md | 2 +- plan/scope-policy.md | 4 +- 34 files changed, 284 insertions(+), 289 deletions(-) create mode 100644 crates/didbot-authstore/Cargo.toml rename crates/{didbot-claim/src/session_store.rs => didbot-authstore/src/lib.rs} (89%) delete mode 100644 crates/didbot-claim/Cargo.toml delete mode 100644 crates/didbot-claim/src/lib.rs rename crates/{didbot-claim/src => didbot-operator/src/operate}/describe.rs (96%) rename crates/{didbot-claim/src => didbot-operator/src/operate}/identify.rs (98%) rename crates/{didbot-claim/src => didbot-operator/src/operate}/orchestrate.rs (96%) rename crates/{didbot-claim/src => didbot-operator/src/operate}/preflight.rs (100%) rename crates/{didbot-claim/src => didbot-operator/src/operate}/record.rs (78%) rename crates/{didbot-claim/src => didbot-operator/src/operate}/scope.rs (63%) rename crates/{didbot-claim/src => didbot-operator/src/operate}/tls.rs (94%) rename crates/{didbot-claim/src => didbot-operator/src/operate}/write.rs (90%) diff --git a/Cargo.lock b/Cargo.lock index 976e7efc..4ee9fbb6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1042,7 +1042,6 @@ dependencies = [ "axum", "didbot-agentd", "didbot-attest", - "didbot-claim", "didbot-data", "didbot-dns", "didbot-http", @@ -1050,6 +1049,7 @@ dependencies = [ "didbot-key", "didbot-lexicon", "didbot-name", + "didbot-operator", "didbot-pds", "didbot-repo", "didbot-serve", @@ -1101,40 +1101,29 @@ dependencies = [ ] [[package]] -name = "didbot-avatar" +name = "didbot-authstore" version = "0.1.0" dependencies = [ - "sha2", + "jacquard-common", + "jacquard-oauth", + "serde", + "serde_json", + "tokio", ] [[package]] -name = "didbot-brand" +name = "didbot-avatar" version = "0.1.0" dependencies = [ - "didbot-avatar", - "didbot-site-anim", + "sha2", ] [[package]] -name = "didbot-claim" +name = "didbot-brand" version = "0.1.0" dependencies = [ - "didbot-claim-check", - "didbot-http", - "didbot-identity", - "didbot-key", - "didbot-onboarding", - "didbot-pds", - "didbot-serve", - "jacquard-common", - "jacquard-oauth", - "reqwest", - "serde", - "serde_json", - "smol_str", - "thiserror", - "time", - "tokio", + "didbot-avatar", + "didbot-site-anim", ] [[package]] @@ -1293,11 +1282,14 @@ name = "didbot-operator" version = "0.1.0" dependencies = [ "clap", - "didbot-claim", + "didbot-authstore", + "didbot-claim-check", "didbot-cli", "didbot-http", + "didbot-identity", + "didbot-key", "didbot-onboarding", - "didbot-serve", + "didbot-scope", "jacquard-common", "jacquard-oauth", "reqwest", @@ -1308,6 +1300,7 @@ dependencies = [ "time", "tokio", "tracing", + "tracing-subscriber", ] [[package]] @@ -1369,11 +1362,11 @@ dependencies = [ name = "didbot-policy-check" version = "0.1.0" dependencies = [ - "didbot-claim", "didbot-claim-check", "didbot-key", "didbot-lexicon", "didbot-onboarding", + "didbot-operator", "didbot-policy", "didbot-policy-cedar", "didbot-policy-records", @@ -1466,7 +1459,7 @@ name = "didbot-scope" version = "0.1.0" dependencies = [ "didbot-lexicon", - "thiserror 2.0.20", + "thiserror", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 573cb7c2..14561e0e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,7 +40,7 @@ didbot-data = { version = "0.1.0", path = "crates/didbot-data" } didbot-repo = { version = "0.1.0", path = "crates/didbot-repo" } didbot-schema = { version = "0.1.0", path = "crates/didbot-schema" } didbot-tls = { version = "0.1.0", path = "crates/didbot-tls" } -didbot-claim = { version = "0.1.0", path = "crates/didbot-claim" } +didbot-authstore = { version = "0.1.0", path = "crates/didbot-authstore" } didbot-claim-check = { version = "0.1.0", path = "crates/didbot-claim-check" } didbot-onboarding = { version = "0.1.0", path = "crates/didbot-onboarding" } didbot-agentd = { version = "0.1.0", path = "crates/didbot-agentd" } diff --git a/crates/didbot-agentd/src/sessions.rs b/crates/didbot-agentd/src/sessions.rs index 35a12776..1f045caf 100644 --- a/crates/didbot-agentd/src/sessions.rs +++ b/crates/didbot-agentd/src/sessions.rs @@ -2,7 +2,7 @@ //! //! A session is what an OAuth client holds once it has signed in: an access //! token, a refresh token, and the DPoP key both are bound to. The session is -//! `jacquard_oauth::session::ClientSessionData`, the same type `didbot-claim` +//! `jacquard_oauth::session::ClientSessionData`, the same type `didbot operate` //! keeps between runs. This module keeps many of them at once, one per //! account, issuer and client. //! diff --git a/crates/didbot-authstore/Cargo.toml b/crates/didbot-authstore/Cargo.toml new file mode 100644 index 00000000..e41476bc --- /dev/null +++ b/crates/didbot-authstore/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "didbot-authstore" +description = "A JSON-file atproto OAuth session store for the commands a person runs on their own machine." +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +publish.workspace = true + +[dependencies] +jacquard-common.workspace = true +jacquard-oauth.workspace = true +serde.workspace = true +serde_json.workspace = true + +[dev-dependencies] +tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } + +[lints] +workspace = true diff --git a/crates/didbot-claim/src/session_store.rs b/crates/didbot-authstore/src/lib.rs similarity index 89% rename from crates/didbot-claim/src/session_store.rs rename to crates/didbot-authstore/src/lib.rs index 1834f599..f9958a35 100644 --- a/crates/didbot-claim/src/session_store.rs +++ b/crates/didbot-authstore/src/lib.rs @@ -1,17 +1,24 @@ -//! Where the operator's signed-in session lives between runs. +//! Where a signed-in atproto session lives between runs of a command a +//! person runs on their own machine. //! -//! The operator signs in once and enrolls hosts afterwards without a -//! browser: each `didbot operate` run resumes the session the last one left, -//! and `jacquard-oauth` refreshes its tokens through this same store as it -//! goes. The file is the operator's own credential to their own account, -//! written `0600` inside a `0700` directory, and it never travels: nothing -//! in this crate sends it anywhere but the operator's own PDS. +//! The person signs in once and runs the command afterwards without a +//! browser: each run resumes the session the last one left, and +//! `jacquard-oauth` refreshes its tokens through this same store as it goes. +//! The file is that person's own credential to their own account, written +//! `0600` inside a `0700` directory, and it travels nowhere — each command +//! sends it only to the account's own PDS. +//! +//! Each command names its own file through [`FileAuthStore::default_path`], +//! so one command's grant is never silently reused by another asking for a +//! different scope. //! //! A pending authorization request — the state between opening the browser //! and the callback landing — is kept in memory only. It is meaningful for //! exactly one run, and a stale one on disk would be a request nothing can //! complete. +#![forbid(unsafe_code)] + use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; @@ -24,7 +31,7 @@ use jacquard_oauth::session::{AuthRequestData, ClientSessionData}; use serde::{Deserialize, Serialize}; /// The file, as written: sessions keyed by `did/session_id`, and the -/// operator's own spelling of who they signed in as, so the next run can +/// person's own spelling of who they signed in as, so the next run can /// find the session from the same handle without resolving it. #[derive(Debug, Default, Serialize, Deserialize)] struct OnDisk { @@ -52,24 +59,24 @@ pub struct FileAuthStore { } impl FileAuthStore { - /// Where the file lives unless the operator says otherwise: - /// `$XDG_CONFIG_HOME/didbot-claim/sessions.json`, or `~/.config/…`. + /// Where `command`'s file lives unless its caller says otherwise: + /// `$XDG_CONFIG_HOME//sessions.json`, or `~/.config/…`. /// /// `None` when neither variable names a directory, which is a process /// with no home to keep a credential in. - pub fn default_path() -> Option { + pub fn default_path(command: &str) -> Option { let base = std::env::var_os("XDG_CONFIG_HOME") .map(PathBuf::from) .filter(|path| path.is_absolute()) .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config")))?; - Some(base.join("didbot-claim").join("sessions.json")) + Some(base.join(command).join("sessions.json")) } /// Opens the store at `path`, reading what an earlier run left there. /// /// A file that does not exist is an empty store; the first sign-in /// creates it. A file that does not parse is refused rather than - /// overwritten: it is the operator's credential, and clobbering it on a + /// overwritten: it is somebody's credential, and clobbering it on a /// read error would sign them out without saying so. pub fn open(path: impl Into) -> std::io::Result { let path = path.into(); @@ -98,7 +105,7 @@ impl FileAuthStore { } /// The session an earlier run left for `input` — the handle or DID the - /// operator signed in as, exactly as they typed it, or a DID any stored + /// person signed in as, exactly as they typed it, or a DID any stored /// session belongs to. pub fn remembered(&self, input: &str) -> Option { let disk = self.disk(); @@ -244,7 +251,7 @@ mod tests { fn scratch(name: &str) -> PathBuf { let dir = std::env::temp_dir().join(format!( - "didbot-claim-sessions-{name}-{}", + "didbot-authstore-sessions-{name}-{}", std::process::id() )); let _ = std::fs::remove_dir_all(&dir); @@ -351,7 +358,7 @@ mod tests { assert_eq!( std::fs::read(&path).expect("still there"), b"not json", - "the operator's file is left exactly as it was" + "the person's file is left exactly as it was" ); } } diff --git a/crates/didbot-claim/Cargo.toml b/crates/didbot-claim/Cargo.toml deleted file mode 100644 index 94620e78..00000000 --- a/crates/didbot-claim/Cargo.toml +++ /dev/null @@ -1,33 +0,0 @@ -[package] -name = "didbot-claim" -description = "What `didbot operate` rests on: the checks a claim makes of a server, the bot.did.operator record it writes into the operator's own repository, and the session it keeps. Runs on the operator's machine, never on the server." -version.workspace = true -edition.workspace = true -rust-version.workspace = true -license.workspace = true -repository.workspace = true -publish.workspace = true - -[dependencies] -didbot-http.workspace = true -didbot-claim-check.workspace = true -didbot-onboarding.workspace = true -didbot-identity.workspace = true -didbot-key.workspace = true -didbot-pds.workspace = true -didbot-serve.workspace = true -jacquard-common = { workspace = true, features = ["reqwest-client"] } -jacquard-oauth.workspace = true -reqwest.workspace = true -serde.workspace = true -serde_json.workspace = true -smol_str.workspace = true -thiserror.workspace = true -time.workspace = true -tokio.workspace = true - -[dev-dependencies] -tokio = { workspace = true, features = ["macros", "rt-multi-thread", "test-util"] } - -[lints] -workspace = true diff --git a/crates/didbot-claim/src/lib.rs b/crates/didbot-claim/src/lib.rs deleted file mode 100644 index ec62ecd8..00000000 --- a/crates/didbot-claim/src/lib.rs +++ /dev/null @@ -1,79 +0,0 @@ -//! What `didbot operate` rests on: the operator's local half of -//! `plan/handshake.md`. -//! -//! This crate runs on the **operator's own machine**, never on the server -//! being claimed -- that is the whole point of it existing separately from -//! anything in `didbot-serve` or `didbot-pds`. In the project owner's own -//! words: *"The local command is important so that we never give our own -//! credentials to the PDS, apart from proving identity (and the delegated -//! DNS ownership creds)."* Nothing in this crate sends an operator -//! credential to the server. The operator's authority reaches the server -//! exclusively as a signed `bot.did.operator` record, written into the -//! operator's *own* repository -- a repository the server can read and can -//! never write to. -//! -//! The command itself is `didbot-operator`'s `operate` verb -//! (`crates/didbot-operator/src/operate.rs`): it signs in, then hands this -//! crate's seams to [`orchestrate::claim`]. -//! -//! # What the checks below actually rest on -//! -//! The root of trust is the hostname itself: the operator delegated that -//! zone, the server proved control of it by passing ACME DNS-01, and the -//! certificate that produced is what TLS now proves belongs to whoever -//! answers the connection (see [`tls`]). Resolving the `did:web` document -//! ([`identify`]) and cross-checking `describeServer` ([`describe`]) are -//! consistency tests layered on top of that connection, not a second proof -//! -- a reader of either module who takes the DID match as the security -//! boundary has it backwards, the same warning `didbot-pds::ownership` -//! gives about the server's own half of this handshake. -//! -//! # Claiming a host the server reserved -//! -//! The same verb claims an identity a server holds for a host -//! (`bot.did.reserveIdentity`): the operator names the hostname the server -//! chose — or the bare name with `--server` — and the record binds the key -//! the host itself presented, which the document publishes under `#node` -//! beside the server's own `#atproto` key. [`identify::vouched_key`] picks -//! by what the document carries, and [`describe`] accepts `describeServer` -//! naming the PDS a host's name sits under. Nothing else differs. -//! -//! # One sign-in -//! -//! The operator's session persists in [`session_store::FileAuthStore`] -//! between runs, so enrolling a second host is one command and no browser. -//! -//! # Idempotence -//! -//! The record this crate writes is keyed by the server's hostname (see -//! [`record`]), and `write` sends `com.atproto.repo.putRecord`, not -//! `createRecord` -- a second run against the same hostname updates the -//! existing claim in place rather than conflicting with it. The one -//! situation that changes anything on a re-run is a rotated server key: -//! [`orchestrate::claim`] always writes whatever `subjectKey` the server's -//! *current* DID document publishes, so re-running this command after a key -//! rotation is the deliberate way an operator notices and re-affirms it. -//! -//! # What is unverified -//! -//! Every seam in this crate ([`tls::TlsProbe`], [`identify::DocumentFetcher`], -//! [`describe::ServerDescriber`], `write::RecordWriter`) is exercised in -//! this crate's own tests against fakes, with no network access. The one -//! thing that cannot be: an actual OAuth round trip -//! (`jacquard_oauth::client::OAuthClient::login_with_local_server`) against -//! a real operator PDS. There is no such server reachable from where this -//! was built, so `src/bin/didbot-claim.rs`'s login step is exercised only -//! by reading `jacquard-oauth`'s own tests and documentation, never run -//! here end to end. - -#![forbid(unsafe_code)] - -pub mod describe; -pub mod identify; -pub mod orchestrate; -pub mod preflight; -pub mod record; -pub mod scope; -pub mod session_store; -pub mod tls; -pub mod write; diff --git a/crates/didbot-identity/src/did.rs b/crates/didbot-identity/src/did.rs index d9f6a14e..9a7edfef 100644 --- a/crates/didbot-identity/src/did.rs +++ b/crates/didbot-identity/src/did.rs @@ -422,7 +422,7 @@ impl AgentDid { /// character set and `%` is not. Anything keying a record by the server /// a DID names has to use this rather than the encoded form /// [`Self::as_str`] carries, and both sides of `bot.did.operator` do: - /// `didbot-claim` writes at this key and the server's own poll reads at + /// `didbot operate` writes at this key and the server's own poll reads at /// it. Deriving it here rather than at each end is what stops the two /// from disagreeing. pub fn authority(&self) -> String { diff --git a/crates/didbot-operator/Cargo.toml b/crates/didbot-operator/Cargo.toml index 9e642858..20e1ef14 100644 --- a/crates/didbot-operator/Cargo.toml +++ b/crates/didbot-operator/Cargo.toml @@ -18,11 +18,18 @@ didbot-cli.workspace = true # `operate` is the claim flow: it signs in to the operator's *own* atproto # account through `jacquard-oauth` and writes `bot.did.operator` there. That # is a different credential from the deployment session `login` mints, and -# the two never meet; see `src/operate.rs`. -didbot-claim.workspace = true +# the two never meet; see `src/operate.rs`. The session it keeps lives in +# `didbot-authstore`'s file. +didbot-authstore.workspace = true +# The checks a claim makes, the record it writes and the scope it asks for +# come from the crates the server and the policy page share; nothing here +# links the server itself, so this binary carries no server code. +didbot-claim-check.workspace = true didbot-http.workspace = true +didbot-identity.workspace = true +didbot-key.workspace = true didbot-onboarding.workspace = true -didbot-serve.workspace = true +didbot-scope.workspace = true jacquard-common = { workspace = true, features = ["reqwest-client"] } jacquard-oauth.workspace = true reqwest.workspace = true @@ -33,6 +40,10 @@ thiserror.workspace = true time.workspace = true tokio.workspace = true tracing.workspace = true +tracing-subscriber.workspace = true + +[dev-dependencies] +tokio = { workspace = true, features = ["macros", "rt-multi-thread", "test-util"] } [lints] workspace = true diff --git a/crates/didbot-operator/src/bin/didbot-operator.rs b/crates/didbot-operator/src/bin/didbot-operator.rs index b40e5d1c..a49ce145 100644 --- a/crates/didbot-operator/src/bin/didbot-operator.rs +++ b/crates/didbot-operator/src/bin/didbot-operator.rs @@ -125,7 +125,13 @@ impl Verb { #[tokio::main] async fn main() -> ExitCode { let cli: Cli = didbot_cli::parse(); - didbot_serve::init_tracing(); + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "didbot_operator=info".into()), + ) + .with_writer(std::io::stderr) + .init(); let program = cli.verb.program(); finish(program, run(cli).await) } diff --git a/crates/didbot-operator/src/operate.rs b/crates/didbot-operator/src/operate.rs index cd3d7ab6..a8b82a13 100644 --- a/crates/didbot-operator/src/operate.rs +++ b/crates/didbot-operator/src/operate.rs @@ -7,8 +7,13 @@ //! deployment — waits for the named server to come up over TLS, checks its //! identity two unauthenticated ways, and writes a `bot.did.operator` record //! into the operator's repository naming that server. Nothing here sends an -//! operator credential to the server. [`didbot_claim`] holds the checks and -//! why they are trustworthy. +//! operator credential to the server. In the project owner's own words: +//! *"The local command is important so that we never give our own +//! credentials to the PDS, apart from proving identity (and the delegated +//! DNS ownership creds)."* The operator's authority reaches the server +//! exclusively as a signed `bot.did.operator` record, written into the +//! operator's *own* repository -- a repository the server can read and can +//! never write to. //! //! The session this signs in with is the operator's credential to their //! own account, kept in [`FileAuthStore`]'s file between runs so a second @@ -21,15 +26,64 @@ //! `describeServer`. It authenticates nothing, opens no browser and writes //! nothing, so it is safe to run repeatedly while waiting for DNS or an ACME //! order. +//! +//! # What the checks rest on +//! +//! The root of trust is the hostname itself: the operator delegated that +//! zone, the server proved control of it by passing ACME DNS-01, and the +//! certificate that produced is what TLS now proves belongs to whoever +//! answers the connection (see [`tls`]). Resolving the `did:web` document +//! ([`identify`]) and cross-checking `describeServer` ([`describe`]) are +//! consistency tests layered on top of that connection, not a second proof +//! -- a reader of either module who takes the DID match as the security +//! boundary has it backwards, the same warning `didbot-pds`'s `ownership` +//! gives about the server's own half of this handshake. +//! +//! # Claiming a host the server reserved +//! +//! The same verb claims an identity a server holds for a host +//! (`bot.did.reserveIdentity`): the operator names the hostname the server +//! chose — or the bare name with `--server` — and the record binds the key +//! the host itself presented, which the document publishes under `#node` +//! beside the server's own `#atproto` key. [`identify::vouched_key`] picks +//! by what the document carries, and [`describe`] accepts `describeServer` +//! naming the PDS a host's name sits under. Nothing else differs. +//! +//! # Idempotence +//! +//! The record is keyed by the server's hostname (see [`record`]), and +//! [`mod@write`] sends `com.atproto.repo.putRecord`, not `createRecord` -- a +//! second run against the same hostname updates the existing claim in +//! place rather than conflicting with it. The one situation that changes +//! anything on a re-run is a rotated server key: [`orchestrate::claim`] +//! always writes whatever `subjectKey` the server's *current* DID document +//! publishes, so re-running after a key rotation is the deliberate way an +//! operator notices and re-affirms it. +//! +//! # What is unverified +//! +//! Every seam here ([`tls::TlsProbe`], [`identify::DocumentFetcher`], +//! [`describe::ServerDescriber`], [`write::RecordWriter`]) is exercised in +//! these modules' own tests against fakes, with no network access. The one +//! thing that cannot be: an actual OAuth round trip +//! (`jacquard_oauth::client::OAuthClient::login_with_local_server`) against +//! a real operator PDS. There is no such server reachable from where this +//! was built, so [`claim`]'s sign-in step is exercised only by reading +//! `jacquard-oauth`'s own tests and documentation, never run here end to +//! end. + +pub mod describe; +pub mod identify; +pub mod orchestrate; +pub mod preflight; +pub mod record; +pub mod scope; +pub mod tls; +pub mod write; use std::time::Duration; -use didbot_claim::describe::HttpServerDescriber; -use didbot_claim::identify::HttpDocumentFetcher; -use didbot_claim::orchestrate::{self, ClaimOutcome}; -use didbot_claim::preflight::{preflight, Report}; -use didbot_claim::session_store::FileAuthStore; -use didbot_claim::tls::ReqwestTlsProbe; +use didbot_authstore::FileAuthStore; use didbot_onboarding::NativeEnvironment; use jacquard_common::session::SessionKey; use jacquard_oauth::atproto::AtprotoClientMetadata; @@ -42,6 +96,12 @@ use smol_str::SmolStr; use time::OffsetDateTime; use tracing::info; +use self::describe::HttpServerDescriber; +use self::identify::HttpDocumentFetcher; +use self::orchestrate::ClaimOutcome; +use self::preflight::{preflight, Report}; +use self::tls::ReqwestTlsProbe; + /// How long a claim waits for TLS before giving up and telling the operator /// to retry. A freshly applied deployment's first ACME DNS-01 order can take /// real minutes once DNS has propagated; ten of them is generous without @@ -129,13 +189,14 @@ pub async fn claim( // here that is genuinely interactive -- a browser window, the operator's // own PDS's consent screen -- and it cannot be exercised in tests // without a real operator PDS. - let scope_string = didbot_claim::scope::operator_write_scope(); + let scope_string = scope::operator_write_scope(); let scopes: Scopes = Scopes::new(SmolStr::new(&scope_string)) - .expect("built from didbot-serve's own scope grammar, always well-formed"); + .expect("built from the server's own scope grammar, always well-formed"); let client_metadata: AtprotoClientMetadata = AtprotoClientMetadata::new_localhost(None, Some(scopes.clone())); let client_data = ClientData::new_public(client_metadata); - let sessions_path = FileAuthStore::default_path().ok_or(OperateError::NoHome)?; + let sessions_path = + FileAuthStore::default_path("didbot-operator").ok_or(OperateError::NoHome)?; let store = FileAuthStore::open(&sessions_path).map_err(|source| OperateError::Store { path: sessions_path.display().to_string(), source, @@ -193,7 +254,7 @@ pub async fn claim( info!(operator_did, "signed in"); // Wait for TLS, resolve the did:web document, cross-check - // describeServer, write. See `didbot_claim::orchestrate::claim`. + // describeServer, write. See [`orchestrate::claim`]. let http = didbot_http::client(); let tls_probe = ReqwestTlsProbe::new(http.clone()); let document_fetcher = HttpDocumentFetcher::new(http.clone()); diff --git a/crates/didbot-claim/src/describe.rs b/crates/didbot-operator/src/operate/describe.rs similarity index 96% rename from crates/didbot-claim/src/describe.rs rename to crates/didbot-operator/src/operate/describe.rs index 6311b812..a1f21e09 100644 --- a/crates/didbot-claim/src/describe.rs +++ b/crates/didbot-operator/src/operate/describe.rs @@ -2,7 +2,7 @@ //! document. //! //! This is not a second proof of anything -- TLS already is the proof, and -//! the DID document resolution in [`crate::identify`] is the first +//! the DID document resolution in [`crate::operate::identify`] is the first //! unauthenticated read of "who does this hostname say it is." What this //! module adds is a second, independent unauthenticated read of the same //! fact, from a different route (an XRPC query rather than a static @@ -29,7 +29,7 @@ struct DescribeServerResponse { /// Answers `com.atproto.server.describeServer`, or reports why it could /// not. The seam a test substitutes; [`HttpServerDescriber`] is the only /// production implementation. -// See `crate::tls::TlsProbe`'s own note on why `async fn` in this trait is +// See `crate::operate::tls::TlsProbe`'s own note on why `async fn` in this trait is // fine here: always used generically, never as a trait object. #[allow(async_fn_in_trait)] pub trait ServerDescriber { @@ -74,7 +74,7 @@ impl ServerDescriber for HttpServerDescriber { /// resolved from the hostname's `did:web` document. `describer_error` names /// what to do when `describeServer` itself could not be reached, distinct /// from a disagreement between the two: the caller already knows TLS -/// answers (this runs after [`crate::tls::wait_for_tls`]), so a transport +/// answers (this runs after [`crate::operate::tls::wait_for_tls`]), so a transport /// failure here means the *application*, not the certificate, is not ready. pub async fn confirm( describer: &D, diff --git a/crates/didbot-claim/src/identify.rs b/crates/didbot-operator/src/operate/identify.rs similarity index 98% rename from crates/didbot-claim/src/identify.rs rename to crates/didbot-operator/src/operate/identify.rs index 5093be72..d502a7eb 100644 --- a/crates/didbot-claim/src/identify.rs +++ b/crates/didbot-operator/src/operate/identify.rs @@ -29,7 +29,7 @@ pub use didbot_claim_check::document::{ /// Fetches a URL's body, or reports why it could not. The seam a test /// substitutes to avoid a real network call; [`HttpDocumentFetcher`] is the /// only production implementation. -// See `crate::tls::TlsProbe`'s own note on why `async fn` in this trait is +// See `crate::operate::tls::TlsProbe`'s own note on why `async fn` in this trait is // fine here: always used generically, never as a trait object. #[allow(async_fn_in_trait)] pub trait DocumentFetcher { diff --git a/crates/didbot-claim/src/orchestrate.rs b/crates/didbot-operator/src/operate/orchestrate.rs similarity index 96% rename from crates/didbot-claim/src/orchestrate.rs rename to crates/didbot-operator/src/operate/orchestrate.rs index 3735c177..fc73978a 100644 --- a/crates/didbot-claim/src/orchestrate.rs +++ b/crates/didbot-operator/src/operate/orchestrate.rs @@ -8,7 +8,7 @@ //! ([`jacquard_oauth::client::OAuthClient::login_with_local_server`]) that //! cannot be exercised in a test without a real operator PDS, so //! `didbot operate` performs it first and hands this function the -//! already-established `operator_did` and a [`crate::write::RecordWriter`]. +//! already-established `operator_did` and a [`crate::operate::write::RecordWriter`]. //! Everything this function *can* be tested against a fake for, is: see //! this module's own tests. //! @@ -22,15 +22,15 @@ use std::time::Duration; +use didbot_claim_check::OperatorClaim; use didbot_identity::document::DidDocument; -use didbot_pds::OperatorClaim; use time::OffsetDateTime; -use crate::describe::{self, ServerDescriber}; -use crate::identify::{self, DocumentFetcher}; -use crate::record; -use crate::tls::{self, TlsProbe}; -use crate::write::{RecordWriter, WriteError}; +use crate::operate::describe::{self, ServerDescriber}; +use crate::operate::identify::{self, DocumentFetcher}; +use crate::operate::record; +use crate::operate::tls::{self, TlsProbe}; +use crate::operate::write::{RecordWriter, WriteError}; /// Every way [`claim`] can end without writing anything. #[derive(Debug, thiserror::Error)] @@ -353,8 +353,8 @@ mod tests { } } - /// The other curve atproto verifies with. This crate cannot mint one, - /// and the server being claimed is not this crate's to constrain, so a + /// The other curve atproto verifies with. This workspace cannot mint + /// one, and the server being claimed is not `operate`'s to constrain, so a /// P-256 server must be claimable. #[tokio::test] async fn a_p256_server_key_is_accepted() { diff --git a/crates/didbot-claim/src/preflight.rs b/crates/didbot-operator/src/operate/preflight.rs similarity index 100% rename from crates/didbot-claim/src/preflight.rs rename to crates/didbot-operator/src/operate/preflight.rs diff --git a/crates/didbot-claim/src/record.rs b/crates/didbot-operator/src/operate/record.rs similarity index 78% rename from crates/didbot-claim/src/record.rs rename to crates/didbot-operator/src/operate/record.rs index 911c55c8..e48ec78c 100644 --- a/crates/didbot-claim/src/record.rs +++ b/crates/didbot-operator/src/operate/record.rs @@ -1,19 +1,19 @@ -//! Building the `bot.did.operator` record this crate writes. +//! Building the `bot.did.operator` record `operate` writes. //! -//! The record's shape is [`didbot_pds::OperatorClaim`] -- the same type the -//! server's own poll parses a claim into (`didbot-serve`'s -//! `ownership_poll::parse_claim`) -- reused here rather than redeclared, so -//! the writer and the reader can never drift out of field-for-field -//! agreement. This module only adds what the reader does not need: turning -//! a claim into the wire JSON a `com.atproto.repo.putRecord` call sends. +//! The record's shape is [`didbot_claim_check::OperatorClaim`] -- the same +//! type the server's own poll parses a claim into -- reused here rather +//! than redeclared, so the writer and the reader can never drift out of +//! field-for-field agreement. This module only adds what the reader does +//! not need: turning a claim into the wire JSON a +//! `com.atproto.repo.putRecord` call sends. -use didbot_pds::OperatorClaim; +use didbot_claim_check::OperatorClaim; use time::format_description::well_known::Rfc3339; pub use didbot_claim_check::record::COLLECTION; /// Builds the claim this command writes: `subject` and `subject_key` come -/// from the server's own DID document (see `crate::identify`), `created_at` +/// from the server's own DID document (see `crate::operate::identify`), `created_at` /// is this command's own clock, and `expires_at` is the operator's choice. pub fn build_claim( subject: String, @@ -34,7 +34,7 @@ pub fn build_claim( /// the policy page writes its own claims with, so the two cannot drift. /// /// Returns an error only if a timestamp cannot be formatted as RFC 3339, -/// which does not happen for any `OffsetDateTime` this crate constructs; +/// which does not happen for any `OffsetDateTime` `operate` constructs; /// kept fallible rather than panicking because formatting is still I/O this /// function does not control the input to indirectly (a caller-supplied /// `expires_at`, in particular). diff --git a/crates/didbot-claim/src/scope.rs b/crates/didbot-operator/src/operate/scope.rs similarity index 63% rename from crates/didbot-claim/src/scope.rs rename to crates/didbot-operator/src/operate/scope.rs index ea566d92..cf53b532 100644 --- a/crates/didbot-claim/src/scope.rs +++ b/crates/didbot-operator/src/operate/scope.rs @@ -3,22 +3,22 @@ //! `plan/handshake.md` is explicit that the whole point of this command is //! that it never sees an operator credential broader than what it needs: a //! single collection, `bot.did.operator`, and only the write actions this -//! command actually performs. `didbot-serve`'s -//! [`didbot_serve::oauth::scope`] already models the atproto granular scope -//! grammar precisely enough to express that -- this module builds the one -//! scope string this command ever requests out of it, rather than -//! hand-writing `"repo:bot.did.operator?action=create,update"` as a literal -//! and hoping it stays syntactically valid. +//! command actually performs. `didbot-scope` models the atproto granular +//! scope grammar precisely enough to express that -- the same grammar the +//! server enforces a ceiling with -- so this module builds the one scope +//! string this command ever requests out of it, rather than hand-writing +//! `"repo:bot.did.operator?action=create,update"` as a literal and hoping +//! it stays syntactically valid. //! //! `create` and `update` both: the record's key is fixed at the server's -//! hostname (see `crate::record`), so a first run creates it and a re-run -//! after a key rotation updates it in place -- see this crate's top-level -//! doc for why that is the deliberate, idempotent behaviour rather than an +//! hostname (see `crate::operate::record`), so a first run creates it and a re-run +//! after a key rotation updates it in place -- see [`crate::operate`] for +//! why that is the deliberate, idempotent behaviour rather than an //! oversight. Neither `delete` nor any other collection is ever requested. use std::collections::BTreeSet; -use didbot_serve::oauth::scope::{Action, ActionSet, NsidPattern, Scope, ScopeSet}; +use didbot_scope::{Action, ActionSet, NsidPattern, Scope, ScopeSet}; /// The scope string this command requests, e.g. /// `"atproto repo:bot.did.operator?action=create,update"`. @@ -26,7 +26,7 @@ pub fn operator_write_scope() -> String { let atoms = vec![ Scope::Atproto, Scope::Repo { - collection: NsidPattern::Exact(crate::record::COLLECTION.to_owned()), + collection: NsidPattern::Exact(crate::operate::record::COLLECTION.to_owned()), actions: ActionSet::Only(BTreeSet::from([Action::Create, Action::Update])), }, ]; @@ -44,7 +44,7 @@ mod tests { } #[test] - fn round_trips_through_didbot_serves_own_parser() { + fn round_trips_through_the_servers_own_parser() { // Not just well-formed by construction -- also accepted by the same // grammar a server enforcing a scope ceiling would parse it with. let scope = operator_write_scope(); diff --git a/crates/didbot-claim/src/tls.rs b/crates/didbot-operator/src/operate/tls.rs similarity index 94% rename from crates/didbot-claim/src/tls.rs rename to crates/didbot-operator/src/operate/tls.rs index 5967e031..3b03571b 100644 --- a/crates/didbot-claim/src/tls.rs +++ b/crates/didbot-operator/src/operate/tls.rs @@ -5,8 +5,8 @@ //! at the new host, and that exchange takes real wall-clock time this //! command has to sit through rather than fail on. What this probe actually //! establishes: TLS is the thing proving the operator is talking to whoever -//! holds the certificate that DNS-01 win produced -- see this crate's -//! top-level doc for why that, not any check this command performs +//! holds the certificate that DNS-01 win produced -- see [`crate::operate`] +//! for why that, not any check this command performs //! afterward, is the actual root of trust. Everything downstream (resolving //! the DID document, `describeServer`, the record write) is a consistency //! check layered on top of a connection this module already trusts once it @@ -19,8 +19,8 @@ use tokio::time::Instant; /// One attempt to reach `hostname` over TLS. The seam a test substitutes; /// [`ReqwestTlsProbe`] is the only production implementation. // `async fn` in a public trait forgoes an auto `Send` bound on its future -- -// fine here, since every use of this trait in this crate is generic (see -// `crate::orchestrate::claim`), never a trait object, and this crate never +// fine here, since every use of this trait is generic (see +// `crate::operate::orchestrate::claim`), never a trait object, and nothing // spawns the resulting future onto another task. #[allow(async_fn_in_trait)] pub trait TlsProbe { @@ -50,7 +50,7 @@ impl TlsProbe for ReqwestTlsProbe { // `describeServer` specifically: it is unauthenticated, and getting // any answer from it -- not just a bare TLS handshake -- means the // application behind the certificate is actually serving requests, - // not merely that a listener is bound. See `crate::describe` for the + // not merely that a listener is bound. See `crate::operate::describe` for the // separate, later check against what it actually says. self.client .get(format!( diff --git a/crates/didbot-claim/src/write.rs b/crates/didbot-operator/src/operate/write.rs similarity index 90% rename from crates/didbot-claim/src/write.rs rename to crates/didbot-operator/src/operate/write.rs index d58c2526..61e79445 100644 --- a/crates/didbot-claim/src/write.rs +++ b/crates/didbot-operator/src/operate/write.rs @@ -2,10 +2,10 @@ //! //! This is the one step in this command that touches the operator's own //! account with write authority, and it is deliberately the last thing this -//! command does -- see [`crate::orchestrate`] for the ordering and why +//! command does -- see [`crate::operate::orchestrate`] for the ordering and why //! nothing calls this module until every earlier check has passed. The //! request itself is `com.atproto.repo.putRecord`, not `createRecord`: the -//! record key is fixed at the server's own authority (see [`crate::record`]), +//! record key is fixed at the server's own authority (see [`crate::operate::record`]), //! so //! `putRecord` is what makes a re-run an *update* of the same claim rather //! than a second, conflicting one -- the idempotence @@ -16,14 +16,14 @@ //! //! `jacquard-oauth`'s `OAuthSession` calls typed `jacquard_common::xrpc::XrpcRequest` //! values -- that is what gets a request DPoP-signed and retried on a stale -//! nonce or an expired token without this crate reimplementing either. No +//! nonce or an expired token without this module reimplementing either. No //! generated binding for `com.atproto.repo.putRecord` is a dependency here //! (`jacquard-api`'s lexicon codegen is not part of this workspace), so //! `PutRecordInput` and `PutRecordResponse` below implement the trait by //! hand, for this one method, rather than pulling in a full generated API //! surface for a single call. -use didbot_pds::OperatorClaim; +use didbot_claim_check::OperatorClaim; use jacquard_common::xrpc::{XrpcClient, XrpcMethod, XrpcRequest, XrpcResp}; use serde::{Deserialize, Serialize}; use smol_str::SmolStr; @@ -74,8 +74,8 @@ impl XrpcRequest for PutRecordInput { #[derive(Debug, thiserror::Error)] pub enum WriteError { /// The record's own fields could not be serialized to RFC 3339. Should - /// not happen for any timestamp this crate constructs; see - /// [`crate::record::to_record_value`]. + /// not happen for any timestamp `operate` constructs; see + /// [`crate::operate::record::to_record_value`]. #[error("could not format the record to write: {0}")] Malformed(#[from] time::error::Format), /// The operator's PDS refused the write. The likeliest cause named @@ -95,11 +95,11 @@ pub enum WriteError { } /// Writes `claim` into the operator's own repository, at the record key -/// [`crate::record::COLLECTION`]/`rkey`. Implemented for anything that +/// [`crate::operate::record::COLLECTION`]/`rkey`. Implemented for anything that /// can make an authenticated XRPC call -- in production, a /// `jacquard_oauth::client::OAuthSession` -- so tests can substitute a fake /// and this module never has to know how the session was authenticated. -// See `crate::tls::TlsProbe`'s own note on why `async fn` in this trait is +// See `crate::operate::tls::TlsProbe`'s own note on why `async fn` in this trait is // fine here: always used generically, never as a trait object. #[allow(async_fn_in_trait)] pub trait RecordWriter { @@ -137,10 +137,10 @@ where rkey: &str, claim: &OperatorClaim, ) -> Result { - let record = crate::record::to_record_value(claim)?; + let record = crate::operate::record::to_record_value(claim)?; let input = PutRecordInput { repo: operator_did.to_owned(), - collection: crate::record::COLLECTION.to_owned(), + collection: crate::operate::record::COLLECTION.to_owned(), rkey: rkey.to_owned(), record, }; diff --git a/crates/didbot-policy-check/Cargo.toml b/crates/didbot-policy-check/Cargo.toml index 37b26f52..5d00ec0d 100644 --- a/crates/didbot-policy-check/Cargo.toml +++ b/crates/didbot-policy-check/Cargo.toml @@ -37,7 +37,7 @@ wasm-bindgen-futures.workspace = true [dev-dependencies] # The command this page's claim must not drift from, and a real key to build # one with. -didbot-claim.workspace = true +didbot-operator.workspace = true didbot-key.workspace = true # `runOnboarding` is this crate's one async export, so its tests need a # runtime to await it on the host. diff --git a/crates/didbot-policy-check/tests/claim.rs b/crates/didbot-policy-check/tests/claim.rs index a7f7bc4b..4415f6a8 100644 --- a/crates/didbot-policy-check/tests/claim.rs +++ b/crates/didbot-policy-check/tests/claim.rs @@ -1,4 +1,4 @@ -//! The record the page writes is the record `didbot-claim` writes. +//! The record the page writes is the record `didbot operate` writes. //! //! Both build it from the server's own two answers, through //! `didbot-claim-check`. This holds the page's export to that: the same @@ -65,15 +65,15 @@ fn the_page_builds_the_record_the_command_builds() { let answered = parsed(&claim_server(&answers(HOST, &document(&did, &key), &did))); assert!(answered.get("error").is_none(), "{answered}"); - // What `didbot-claim` sends, from its own builder over the same facts. - let claim = didbot_claim::record::build_claim( + // What `didbot operate` sends, from its own builder over the same facts. + let claim = didbot_operator::operate::record::build_claim( did.clone(), key.clone(), time::OffsetDateTime::parse(CREATED, &time::format_description::well_known::Rfc3339) .expect("an instant"), None, ); - let written = didbot_claim::record::to_record_value(&claim).expect("formats"); + let written = didbot_operator::operate::record::to_record_value(&claim).expect("formats"); assert_eq!(answered["record"], written); assert_eq!( diff --git a/crates/didbot/Cargo.toml b/crates/didbot/Cargo.toml index 8c1c6b76..23ede896 100644 --- a/crates/didbot/Cargo.toml +++ b/crates/didbot/Cargo.toml @@ -32,7 +32,7 @@ didbot-repo.workspace = true # `tests/handshake.rs` can run the real bootstrap: this is the only crate that # can hold the writing side and the reading side of `bot.did.operator` at the # same time, which is the same reason the conformance suites live here. -didbot-claim.workspace = true +didbot-operator.workspace = true # The agent host's daemon, so `tests/reservation.rs` can drive a host from # its reservation to its contexts' accounts against the real router. didbot-agentd.workspace = true diff --git a/crates/didbot/tests/handshake.rs b/crates/didbot/tests/handshake.rs index ec14c46e..23ef307a 100644 --- a/crates/didbot/tests/handshake.rs +++ b/crates/didbot/tests/handshake.rs @@ -24,7 +24,7 @@ //! //! Every other test covers one side of this — `didbot-pds`'s `ownership` //! tests decide against an in-memory claim source, `didbot-serve`'s -//! `ownership_poll` tests fetch against a loopback PDS, `didbot-claim`'s +//! `ownership_poll` tests fetch against a loopback PDS, `didbot operate`'s //! tests write against a fake — and none can see whether the writing side //! and the reading side agree about what a claim *is*. This one holds both //! at once, so a field-name or key-format disagreement between them fails @@ -33,13 +33,13 @@ //! What is real: the `Provisioner` that generates the server's keys, DID //! and its own `bot.did.registration`, `didbot_serve::ownership_poll`'s //! `configure` and `poll_once` (the same calls the server binary makes), -//! `didbot_claim::orchestrate::claim`, and a fake operator PDS answering +//! `didbot_operator::operate::orchestrate::claim`, and a fake operator PDS answering //! real `getRecord`/`putRecord` over a real loopback socket. //! //! What is substituted, and why: the didbot server's own transport, handed //! to the router directly as in `end_to_end.rs`; the clock, passed to //! `poll_once` so a six-hour window can be crossed without waiting six -//! hours; and TLS, which `didbot_claim::tls::TlsProbe` exists to stand in +//! hours; and TLS, which `didbot_operator::operate::tls::TlsProbe` exists to stand in //! for — what it proves needs a certificate authority, not a test. use std::collections::HashMap; @@ -63,9 +63,9 @@ use didbot::pds::{ OwnershipTransition, Provisioner, Registry, ServerLifecycle, DEFAULT_GRACE_WINDOW, }; use didbot::serve::{app_with_auth, ownership_poll, AuthState}; -use didbot_claim::describe::ServerDescriber; -use didbot_claim::identify::DocumentFetcher; -use didbot_claim::write::{RecordWriter, WriteError}; +use didbot_operator::operate::describe::ServerDescriber; +use didbot_operator::operate::identify::DocumentFetcher; +use didbot_operator::operate::write::{RecordWriter, WriteError}; const ZONE: &str = "agents.localhost"; @@ -288,7 +288,7 @@ async fn fake_operator_pds() -> OperatorPds { /// single-process test cannot produce; see this file's own header. struct TlsUp; -impl didbot_claim::tls::TlsProbe for TlsUp { +impl didbot_operator::operate::tls::TlsProbe for TlsUp { async fn probe(&self, _hostname: &str) -> Result<(), String> { Ok(()) } @@ -358,7 +358,7 @@ impl ServerDescriber for RouterDescriber { /// Writes into the operator's own repository over real HTTP, standing in for /// the DPoP-signed `OAuthSession` the real command holds. The request body -/// is the one `didbot_claim::write` sends, built by the same +/// is the one `didbot_operator::operate::write` sends, built by the same /// `record::to_record_value`, so the record that lands here is the record a /// real run would land. struct HttpWriter { @@ -373,13 +373,13 @@ impl RecordWriter for HttpWriter { rkey: &str, claim: &OperatorClaim, ) -> Result { - let record = didbot_claim::record::to_record_value(claim)?; + let record = didbot_operator::operate::record::to_record_value(claim)?; let response = self .client .post(format!("{}/xrpc/com.atproto.repo.putRecord", self.endpoint)) .json(&json!({ "repo": operator_did, - "collection": didbot_claim::record::COLLECTION, + "collection": didbot_operator::operate::record::COLLECTION, "rkey": rkey, "record": record, })) @@ -410,13 +410,16 @@ fn split_url(url: &str) -> (String, String) { /// Runs the operator's local command against `hostname`, writing into /// `pds`'s repository. Everything but authentication and TLS is the real -/// `didbot_claim::orchestrate::claim`. +/// `didbot_operator::operate::orchestrate::claim`. async fn run_claim_command( app: &Router, pds: &OperatorPds, hostname: &str, -) -> Result { - didbot_claim::orchestrate::claim( +) -> Result< + didbot_operator::operate::orchestrate::ClaimOutcome, + didbot_operator::operate::orchestrate::ClaimError, +> { + didbot_operator::operate::orchestrate::claim( hostname, &pds.did, &TlsUp, @@ -971,7 +974,7 @@ async fn the_claim_command_writes_nothing_when_the_server_contradicts_itself() { let pds = fake_operator_pds().await; let booted = boot(&pds).await; - let result = didbot_claim::orchestrate::claim( + let result = didbot_operator::operate::orchestrate::claim( ZONE, &pds.did, &TlsUp, @@ -994,7 +997,9 @@ async fn the_claim_command_writes_nothing_when_the_server_contradicts_itself() { assert!( matches!( result, - Err(didbot_claim::orchestrate::ClaimError::Describe(_)) + Err(didbot_operator::operate::orchestrate::ClaimError::Describe( + _ + )) ), "the command must refuse rather than write: {result:?}" ); @@ -1059,7 +1064,7 @@ async fn a_server_on_a_port_is_claimed_at_the_key_its_reader_looks_up() { .to_string(); let pds = fake_operator_pds().await; - let outcome = didbot_claim::orchestrate::claim( + let outcome = didbot_operator::operate::orchestrate::claim( TYPED, &pds.did, &TlsUp, diff --git a/crates/didbot/tests/reservation.rs b/crates/didbot/tests/reservation.rs index 99e53750..7f403479 100644 --- a/crates/didbot/tests/reservation.rs +++ b/crates/didbot/tests/reservation.rs @@ -175,13 +175,14 @@ impl OperatorPds { subject_key: &str, expires_at: Option, ) { - let claim = didbot_claim::record::build_claim( + let claim = didbot_operator::operate::record::build_claim( subject.to_owned(), subject_key.to_owned(), OffsetDateTime::now_utc(), expires_at, ); - let record = didbot_claim::record::to_record_value(&claim).expect("a claim formats"); + let record = + didbot_operator::operate::record::to_record_value(&claim).expect("a claim formats"); self.records .lock() .expect("not poisoned") @@ -315,7 +316,7 @@ async fn fake_operator_pds() -> OperatorPds { /// test. struct TlsUp; -impl didbot_claim::tls::TlsProbe for TlsUp { +impl didbot_operator::operate::tls::TlsProbe for TlsUp { async fn probe(&self, _hostname: &str) -> Result<(), String> { Ok(()) } @@ -325,7 +326,7 @@ impl didbot_claim::tls::TlsProbe for TlsUp { /// it off a socket: by the URL's host, which for a host is its own name. struct RouterFetcher(Router); -impl didbot_claim::identify::DocumentFetcher for RouterFetcher { +impl didbot_operator::operate::identify::DocumentFetcher for RouterFetcher { async fn fetch(&self, url: &str) -> Result { let rest = url.split_once("://").map_or(url, |(_, rest)| rest); let (host, path) = rest @@ -362,7 +363,7 @@ impl didbot_claim::identify::DocumentFetcher for RouterFetcher { /// `describeServer` over the same router, at the hostname being claimed. struct RouterDescriber(Router); -impl didbot_claim::describe::ServerDescriber for RouterDescriber { +impl didbot_operator::operate::describe::ServerDescriber for RouterDescriber { async fn describe(&self, hostname: &str) -> Result { let response = self .0 @@ -389,46 +390,46 @@ impl didbot_claim::describe::ServerDescriber for RouterDescriber { /// Writes into the operator's repository over real HTTP, standing in for /// the DPoP-signed session the real command holds; the body is the one -/// `didbot_claim::write` sends. +/// `didbot_operator::operate::write` sends. struct HttpWriter { client: reqwest::Client, endpoint: String, } -impl didbot_claim::write::RecordWriter for HttpWriter { +impl didbot_operator::operate::write::RecordWriter for HttpWriter { async fn put_operator_record( &self, operator_did: &str, rkey: &str, claim: &didbot::pds::OperatorClaim, - ) -> Result { - let record = didbot_claim::record::to_record_value(claim)?; + ) -> Result { + let record = didbot_operator::operate::record::to_record_value(claim)?; let response = self .client .post(format!("{}/xrpc/com.atproto.repo.putRecord", self.endpoint)) .json(&json!({ "repo": operator_did, - "collection": didbot_claim::record::COLLECTION, + "collection": didbot_operator::operate::record::COLLECTION, "rkey": rkey, "record": record, })) .send() .await - .map_err(|error| didbot_claim::write::WriteError::Refused { - detail: error.to_string(), - })?; + .map_err( + |error| didbot_operator::operate::write::WriteError::Refused { + detail: error.to_string(), + }, + )?; if !response.status().is_success() { - return Err(didbot_claim::write::WriteError::Refused { + return Err(didbot_operator::operate::write::WriteError::Refused { detail: format!("putRecord answered {}", response.status()), }); } - let body: Value = - response - .json() - .await - .map_err(|error| didbot_claim::write::WriteError::Refused { - detail: error.to_string(), - })?; + let body: Value = response.json().await.map_err(|error| { + didbot_operator::operate::write::WriteError::Refused { + detail: error.to_string(), + } + })?; Ok(body["uri"].as_str().unwrap_or_default().to_owned()) } } @@ -1301,7 +1302,7 @@ async fn the_claim_command_vouches_for_a_reserved_host_by_name() { let did = body["did"].as_str().expect("a did").to_owned(); let hostname = body["hostname"].as_str().expect("a hostname").to_owned(); - let outcome = didbot_claim::orchestrate::claim( + let outcome = didbot_operator::operate::orchestrate::claim( &hostname, &owned.pds.did, &TlsUp, diff --git a/deny.toml b/deny.toml index a144e519..bb710379 100644 --- a/deny.toml +++ b/deny.toml @@ -52,7 +52,7 @@ ignore = [ # The Marvin Attack is a timing side-channel in RSA *private-key* # operations, observable over a network. Both users here are public-key # only: jacquard-oauth reads an authorization server's published JWK set - # so didbot-claim can authenticate as an account, and didbot-swarm's own + # so didbot-operator can authenticate as an account, and didbot-swarm's own # DPoP key is ES256 (crates/didbot-swarm/src/oauth.rs). No crate in this # workspace names an `rsa` type or holds an RSA private key. Re-check # this reasoning if either gains a private-key code path. diff --git a/plan/adversarial.md b/plan/adversarial.md index ec5c190f..4d779613 100644 --- a/plan/adversarial.md +++ b/plan/adversarial.md @@ -319,7 +319,7 @@ doc comment, and each was run. actually be a socket. The function's own doc already said it should refuse to guess; the code did the opposite. - **The scope ceiling, as an invariant rather than a table.** Eight tests - in `crates/didbot-serve/src/oauth/scope.rs`'s `ceiling_boundary` + in `crates/didbot-scope/src/lib.rs`'s `ceiling_boundary` module, over every pairing of a corpus built out of *adjacent* atoms: nothing granted is outside the ceiling, nothing granted is outside the request, re-applying a ceiling changes nothing, reversing a ceiling diff --git a/plan/credentials.md b/plan/credentials.md index 61c84e6e..2d0a7746 100644 --- a/plan/credentials.md +++ b/plan/credentials.md @@ -95,7 +95,7 @@ section decides what replaces it before any of it is built. access token and its expiry, the refresh token, the granted scopes, the DPoP private key, the last nonce from each of the authorization server and the resource server, and the endpoints it refreshes against. That - is `jacquard_oauth::session::ClientSessionData`, which `didbot-claim` + is `jacquard_oauth::session::ClientSessionData`, which `didbot operate` already keeps. `didbot_agentd::sessions::SessionStore` keeps many at once, one `0600` file each in a `0700` directory, written through a synced temporary and a rename. One stored copy can serve both the diff --git a/plan/dedupe-audit.md b/plan/dedupe-audit.md index 12ba6f63..12dd60fd 100644 --- a/plan/dedupe-audit.md +++ b/plan/dedupe-audit.md @@ -63,7 +63,7 @@ each carry a doc comment giving a specific, still-true reason for the allow. None looked stale enough to flag. Fake/mock/fixture types (`didbot-dns::route53`'s transport fake, -`didbot-claim`'s several, `didbot-serve`'s test doubles) each implement +`didbot-operator`'s several, `didbot-serve`'s test doubles) each implement a trait local to their own crate. Consolidating any of them would add a cross-crate test dependency to share one struct; not done, per the maintenance-budget guidance against extracting a shared crate for a few diff --git a/plan/handshake.md b/plan/handshake.md index 5b8eb592..d59e479c 100644 --- a/plan/handshake.md +++ b/plan/handshake.md @@ -2,7 +2,7 @@ id: handshake title: A server and its operator establish each other, with no shared secret status: open -crates: [didbot-pds, didbot-serve, didbot-lexicon, didbot-attest, didbot-claim] +crates: [didbot-pds, didbot-serve, didbot-lexicon, didbot-attest, didbot-operator] dependsOn: [ownership, policy-store, alerts] exitCriterion: > An operator points a local command at a freshly booted PDS that vouches for @@ -297,14 +297,15 @@ neither the operator nor a channel to them. or re-prove), pauses past a six-hour grace window, resumes on its own when the claim reappears. `Estop::Cause` distinguishes this from an operator's own pause. -- [x] **The command itself**, `didbot operate` (`crates/didbot-claim`, run as - `didbot operate `) — a crate of its - own, because it authenticates as the operator's own atproto account - and nothing else on an operator's machine does; see the crate's own - top-level doc for the full case. It signs +- [x] **The command itself**, `didbot operate` (`didbot_operator::operate`, + run as `didbot operate `) — on the + operator's own machine, in a binary that links no server code, + because it authenticates as the operator's own atproto account and + nothing else on an operator's machine does; see the module's own doc + for the full case. It signs into the operator's own account with the narrow scope `atproto repo:bot.did.operator?action=create,update`, built from - `didbot-serve::oauth::scope`'s grammar rather than hand-written (this + `didbot-scope`'s grammar rather than hand-written (this answers the write-scope open question this list previously carried: the granular grammar expresses it exactly, no `rpc:`/`blob:` needed); waits for TLS; resolves the PDS's `did:web` document; cross-checks @@ -356,7 +357,7 @@ neither the operator nor a channel to them. - [x] **The claim command holds a session between runs.** The operator signs in once and enrolls hosts afterwards without a browser, so its tokens persist and refresh rather than being acquired per run. - `didbot_claim::session_store::FileAuthStore`, `0600` under the + `didbot_authstore::FileAuthStore`, `0600` under the operator's config directory; a run resumes what the last one left and opens a browser only when nothing resumes. diff --git a/plan/node.md b/plan/node.md index adddc34e..c9016b7f 100644 --- a/plan/node.md +++ b/plan/node.md @@ -82,7 +82,7 @@ which is what keeps a credential out of every place the model can read. `NodeCredentialBackend` rather than a new dependency — and `deny.toml` denies multiple versions, so a third curve crate would not be cheap later. Carry the public half as a multikey and dispatch on the codec the - way `didbot-claim`'s `identify` already does, so the curve stays a + way `didbot operate`'s `identify` already does, so the curve stays a per-host fact. **This is a credential type and needs a human's approval before it is built.** - [ ] **The bootstrap, concretely.** How a host gets its credential in the first @@ -218,7 +218,7 @@ load leaves another. exclusive lock beside the key for the daemon's life, and the identity the key is reserved under is written by the same process. - [x] **New crate, not a role something already running grows.** There is no - daemon on an agent host to grow: `didbot-claim` is a one-shot command, + daemon on an agent host to grow: `didbot operate` is a one-shot command, `didbot-swarm` is a load generator, and `didbot-pds` is the server `docs/deployment.md` keeps off a laptop on purpose. Growing the server would put credential issuance inside the process holding every signing diff --git a/plan/oauth.md b/plan/oauth.md index 962f702f..8ccc1d9a 100644 --- a/plan/oauth.md +++ b/plan/oauth.md @@ -340,7 +340,8 @@ both gone. fast path for a client that printed its URL in the tool call being reported, fetched with `getAuthorization` when no poll has delivered it yet. -- [x] **The granular atproto scope grammar.** `crates/didbot-serve/src/oauth/scope.rs`: +- [x] **The granular atproto scope grammar.** `crates/didbot-scope`, re-exported + as `didbot_serve::oauth::scope`: `Scope::parse`/`ScopeSet::parse` read the wire grammar directly, not a parallel one — `repo:`, `rpc:`, `blob:`, `identity:`, `account:`, `include:`, `atproto`, and the three `transition:*` legacy scopes. @@ -354,7 +355,7 @@ both gone. fails the whole scope string at parse time — see the module doc's "Unknown prefixes" section — so a client sending a prefix this server doesn't implement gets no grant at all, rather than a grant scoped to - whatever this server understood. `crates/didbot-claim/src/scope.rs` is + whatever this server understood. `didbot_operator::operate::scope` is the first real consumer, building `didbot operate`'s own request scope out of the grammar instead of a hand-written literal. - [x] **DPoP, verifier side.** `crates/didbot-serve/src/oauth/dpop.rs`: @@ -379,7 +380,7 @@ both gone. carries a refusal as text, so the `DPoP-Nonce` header and the `use_dpop_nonce` code a client retries on do not survive the seam every route holds the verifier through. And `jacquard-oauth` 0.12.1 — the - client `didbot-agentd`, `didbot-swarm` and `didbot-claim` all drive — + client `didbot-agentd`, `didbot-swarm` and `didbot-operator` all drive — retries an authorization-server nonce challenge only on a `400` carrying `{"error":"use_dpop_nonce"}`, where `DpopError`'s own response is a `401`. Requiring a nonce before both hold refuses every one of diff --git a/plan/onboarding-policies.md b/plan/onboarding-policies.md index faad2804..ce3da6b2 100644 --- a/plan/onboarding-policies.md +++ b/plan/onboarding-policies.md @@ -2,7 +2,7 @@ id: onboarding-policies title: A new operator is shown policies worth having, not an empty ruleset status: open -crates: [didbot-policy-source, didbot-serve, didbot-claim] +crates: [didbot-policy-source, didbot-serve, didbot-operator] dependsOn: [policy, onboarding] exitCriterion: > An operator claiming a fresh deployment is offered a small set of sample diff --git a/plan/ownership.md b/plan/ownership.md index 60ec1d6f..4550150b 100644 --- a/plan/ownership.md +++ b/plan/ownership.md @@ -104,7 +104,7 @@ those two. owner-to-server is the whole of it and an agent's own owner rides in its registration record instead. `a_clean_run_writes_the_servers_resolved_key` and the three refusal - tests beside it in `crates/didbot-claim/src/orchestrate.rs` show + tests beside it in `crates/didbot-operator/src/operate/orchestrate.rs` show nothing is written until every check passes. Reading one back is *not* built, and this item used to say it was. The diff --git a/plan/scope-policy.md b/plan/scope-policy.md index 2e78d070..408b1a17 100644 --- a/plan/scope-policy.md +++ b/plan/scope-policy.md @@ -16,8 +16,8 @@ denials only, evaluated at the write, from three sources — and where the two disagree, this file is wrong. Read that one first. **This was `blocked` and is not.** Both things it waited on are in the tree: -[oauth](oauth.md)'s granular grammar landed -(`crates/didbot-serve/src/oauth/scope.rs`, with `Scope::contains`, +[oauth](oauth.md)'s granular grammar landed (`crates/didbot-scope`, +re-exported as `didbot_serve::oauth::scope`, with `Scope::contains`, `Scope::intersect` and `ScopeSet::intersect`), and the hook this epic exists to fill is called on the live path — `ScopePolicy::ceiling`, at PAR, whose only implementations today are `GrantAnyScope` and `ConfiguredCeiling`. A -- 2.51.2