feat: add setup did command to publish labeler identity

`cargo run --bin setup -- did` adds #atproto_label and #atproto_labeler to the
DID document via the PDS-signed PLC flow, prompting for the main password and
the emailed code. Splits setup into declare/did modules.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
govandClaude Opus 5.5 committed 2026-11-06 15:18:00 -05:00
1 parent 3c0d56cdaf
commit b4ac30c010
7 files changed
+199 -28

No files matched your search

+5 -16
View File
@@ -1,9 +1,7 @@
//! One-time labeler setup.
//!
//! `cargo run --bin setup -- declare` publishes the labeler declaration
//! (`app.bsky.labeler.service/self`) with one label per city. This is what
//! gives the account a Subscribe button. Safe to re-run after editing the list.
use anyhow::{Result, bail};
//! Publishes the labeler declaration (`app.bsky.labeler.service/self`) with one
//! label per city. This is what gives the account a Subscribe button. Safe to
//! re-run after editing the city list; it overwrites the record.
use anyhow::Result;
use cities_labeler::cities::Cities;
use cities_labeler::config::Config;
use cities_labeler::login;
@@ -18,16 +16,7 @@ use jacquard::types::language::Language;
use jacquard::types::recordkey::RecordKey;
use jacquard::types::string::Datetime;
#[tokio::main]
async fn main() -> Result<()> {
let config = Config::from_env()?;
match std::env::args().nth(1).as_deref() {
Some("declare") => declare(&config).await,
_ => bail!("usage: setup declare"),
}
}
async fn declare(config: &Config) -> Result<()> {
pub async fn run(config: &Config) -> Result<()> {
let cities = Cities::load(&config.cities_path)?;
let (agent, did) = login(&config.handle, &config.app_password).await?;
+138
View File
@@ -0,0 +1,138 @@
//! Adds the label signing key (`#atproto_label`) and the labeler endpoint
//! (`#atproto_labeler`) to the account's DID document. Needs the account's main
//! password and a code emailed by the PDS. Run it once the labeler is live at
//! `LABELER_URL`; it exits early if both entries are already in place.
use std::collections::BTreeMap;
use std::io::Write;
use anyhow::{Context, Result, bail};
use cities_labeler::config::Config;
use cities_labeler::login;
use jacquard::AuthorizationToken;
use jacquard::api::com_atproto::identity::sign_plc_operation::SignPlcOperation;
use jacquard::api::com_atproto::identity::submit_plc_operation::SubmitPlcOperation;
use jacquard::client::{Agent, MemoryCredentialSession};
use jacquard::common::to_data;
use jacquard::common::types::crypto::multikey;
use jacquard::prelude::*;
use k256::ecdsa::SigningKey;
use serde::{Deserialize, Serialize};
const PLC_DIRECTORY: &str = "https://plc.directory";
/// The parts of the DID's current PLC state we change; everything else
/// (rotation keys, handle) is left as the PDS has it.
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct PlcData {
verification_methods: BTreeMap<String, String>,
services: BTreeMap<String, PlcService>,
}
#[derive(Debug, PartialEq, Deserialize, Serialize)]
struct PlcService {
#[serde(rename = "type")]
kind: String,
endpoint: String,
}
pub async fn run(config: &Config) -> Result<()> {
let key = SigningKey::from_slice(&hex::decode(&config.signing_key_hex)?)
.context("SIGNING_KEY_HEX")?;
let public = key.verifying_key().to_encoded_point(true);
let label_key = format!("did:key:{}", multikey(0xE7, public.as_bytes()));
let labeler = PlcService {
kind: "AtprotoLabeler".into(),
endpoint: config.labeler_url.clone(),
};
// identity changes need the main password; app passwords are refused
let password = rpassword::prompt_password(format!("main password for {}: ", config.handle))?;
let (agent, did) = login(&config.handle, &password).await?;
let url = format!("{PLC_DIRECTORY}/{did}/data");
let mut plc: PlcData = reqwest::get(&url)
.await?
.error_for_status()?
.json()
.await
.with_context(|| format!("reading {url}"))?;
if plc.verification_methods.get("atproto_label") == Some(&label_key)
&& plc.services.get("atproto_labeler") == Some(&labeler)
{
println!("{did} already points at {}", config.labeler_url);
return Ok(());
}
plc.verification_methods
.insert("atproto_label".into(), label_key.clone());
plc.services.insert("atproto_labeler".into(), labeler);
println!(
"will set on {did}:\n #atproto_label {label_key}\n #atproto_labeler {}",
config.labeler_url
);
request_signature(&agent).await?;
let token = prompt("code emailed to the account: ")?;
let signed = agent
.send(SignPlcOperation {
token: Some(token.as_str().into()),
verification_methods: Some(to_data(&plc.verification_methods)?),
services: Some(to_data(&plc.services)?),
also_known_as: None,
rotation_keys: None,
extra_data: None,
})
.await?
.into_output()?;
if prompt("submit this PLC operation? [y/N] ")? != "y" {
bail!("aborted; nothing submitted");
}
let response = agent
.send(SubmitPlcOperation {
operation: signed.operation,
extra_data: None,
})
.await?;
// success is an empty body, which into_output() can't decode as JSON
if !response.status().is_success() {
response.into_output()?;
bail!("submitPlcOperation failed");
}
println!("submitted; check {PLC_DIRECTORY}/{did}");
Ok(())
}
/// Asks the PDS to email a PLC operation code. Sent by hand because jacquard
/// serializes this no-input procedure as a `null` JSON body, which the PDS
/// rejects with "A request body was provided when none was expected".
async fn request_signature(agent: &Agent<MemoryCredentialSession>) -> Result<()> {
let Some(AuthorizationToken::Bearer(token)) = agent.inner().access_token().await else {
bail!("not logged in");
};
let url = format!(
"{}/xrpc/com.atproto.identity.requestPlcOperationSignature",
agent.endpoint().await.as_str().trim_end_matches('/')
);
let response = reqwest::Client::new()
.post(&url)
.bearer_auth(token.as_str())
.send()
.await?;
if !response.status().is_success() {
bail!(
"requestPlcOperationSignature failed: {} {}",
response.status(),
response.text().await?
);
}
Ok(())
}
fn prompt(label: &str) -> Result<String> {
print!("{label}");
std::io::stdout().flush()?;
let mut line = String::new();
std::io::stdin().read_line(&mut line)?;
Ok(line.trim().to_owned())
}
+19
View File
@@ -0,0 +1,19 @@
//! One-time labeler setup.
//!
//! - `cargo run --bin setup -- declare` publishes the label definitions.
//! - `cargo run --bin setup -- did` publishes the labeler's identity entries.
mod declare;
mod did;
use anyhow::{Result, bail};
use cities_labeler::config::Config;
#[tokio::main]
async fn main() -> Result<()> {
let config = Config::from_env()?;
match std::env::args().nth(1).as_deref() {
Some("declare") => declare::run(&config).await,
Some("did") => did::run(&config).await,
_ => bail!("usage: setup <declare|did>"),
}
}