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

Generated
+23
View File
@@ -461,6 +461,8 @@ dependencies = [
"jacquard-axum", "jacquard-axum",
"k256", "k256",
"rand_core 0.6.4", "rand_core 0.6.4",
"reqwest",
"rpassword",
"rusqlite", "rusqlite",
"serde", "serde",
"serde_ipld_dagcbor", "serde_ipld_dagcbor",
@@ -2847,6 +2849,17 @@ dependencies = [
"windows-sys 0.52.0", "windows-sys 0.52.0",
] ]
[[package]]
name = "rpassword"
version = "7.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2da316a15f47e3d053de9cb2c439650bd8fa4aaeb9365f2e5f27f492ff73c196"
dependencies = [
"libc",
"rtoolbox",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "rsa" name = "rsa"
version = "0.9.10" version = "0.9.10"
@@ -2867,6 +2880,16 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "rtoolbox"
version = "0.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a1efe12a1469752d0e6ff5ebec0b6ef4924cc5c4c71046b0ec730040535819d"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "rusqlite" name = "rusqlite"
version = "0.37.0" version = "0.37.0"
+2
View File
@@ -14,6 +14,8 @@ jacquard = { version = "=0.12.2", default-features = false, features = ["api_blu
jacquard-axum = "=0.12.2" jacquard-axum = "=0.12.2"
k256 = { version = "0.13", features = ["ecdsa"] } k256 = { version = "0.13", features = ["ecdsa"] }
rand_core = { version = "0.6", features = ["getrandom"] } rand_core = { version = "0.6", features = ["getrandom"] }
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
rpassword = "7"
rusqlite = { version = "0.37", features = ["bundled"] } rusqlite = { version = "0.37", features = ["bundled"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_ipld_dagcbor = "0.6" serde_ipld_dagcbor = "0.6"
+9 -12
View File
@@ -1,4 +1,4 @@
# cosocial cities labeler # Canadian Cities Labeler
An atproto labeler that puts a Canadian city badge on accounts that ask for one over Bluesky DMs. An atproto labeler that puts a Canadian city badge on accounts that ask for one over Bluesky DMs.
@@ -10,17 +10,6 @@ The labeler polls its DMs, matches the city against `data/cities.json`, and sign
label (e.g. `halifax-ns`), negating any previous one. Labels are stored in SQLite and served over label (e.g. `halifax-ns`), negating any previous one. Labels are stored in SQLite and served over
`com.atproto.label.subscribeLabels` and `queryLabels`. `com.atproto.label.subscribeLabels` and `queryLabels`.
```
src/
dms.rs DM polling, onboarding, replies
cities.rs city list and matching
labeler.rs set_city: negate the old label, apply the new one
labels.rs label signing
server.rs queryLabels + subscribeLabels (axum, jacquard-axum)
db.rs SQLite
bin/keygen.rs
```
Built on [jacquard](https://tangled.org/nonbinary.computer/jacquard) for atproto types and XRPC. Built on [jacquard](https://tangled.org/nonbinary.computer/jacquard) for atproto types and XRPC.
## Local test ## Local test
@@ -39,3 +28,11 @@ DM it from a test account, then check
Labels won't appear in the Bluesky app until the labeler is deployed publicly and its DID Labels won't appear in the Bluesky app until the labeler is deployed publicly and its DID
document has the `#atproto_label` key and `#atproto_labeler` service. document has the `#atproto_label` key and `#atproto_labeler` service.
## First time setup
```sh
cargo run --bin setup -- declare # label definitions; gives the account a Subscribe button
cargo run --bin setup -- did # run after deploying; requires labeler account's main password + emailed code
```
+5 -16
View File
@@ -1,9 +1,7 @@
//! One-time labeler setup. //! 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
//! `cargo run --bin setup -- declare` publishes the labeler declaration //! re-run after editing the city list; it overwrites the record.
//! (`app.bsky.labeler.service/self`) with one label per city. This is what use anyhow::Result;
//! gives the account a Subscribe button. Safe to re-run after editing the list.
use anyhow::{Result, bail};
use cities_labeler::cities::Cities; use cities_labeler::cities::Cities;
use cities_labeler::config::Config; use cities_labeler::config::Config;
use cities_labeler::login; use cities_labeler::login;
@@ -18,16 +16,7 @@ use jacquard::types::language::Language;
use jacquard::types::recordkey::RecordKey; use jacquard::types::recordkey::RecordKey;
use jacquard::types::string::Datetime; use jacquard::types::string::Datetime;
#[tokio::main] pub async fn run(config: &Config) -> Result<()> {
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<()> {
let cities = Cities::load(&config.cities_path)?; let cities = Cities::load(&config.cities_path)?;
let (agent, did) = login(&config.handle, &config.app_password).await?; 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>"),
}
}
+3
View File
@@ -8,6 +8,8 @@ pub struct Config {
pub port: u16, pub port: u16,
pub db_path: String, pub db_path: String,
pub cities_path: String, pub cities_path: String,
/// Public HTTPS URL where this labeler serves its XRPC endpoints.
pub labeler_url: String,
} }
fn required(name: &str) -> Result<String> { fn required(name: &str) -> Result<String> {
@@ -28,6 +30,7 @@ impl Config {
port: optional("PORT", "4100").parse().context("PORT")?, port: optional("PORT", "4100").parse().context("PORT")?,
db_path: optional("DB_PATH", "labeler.sqlite"), db_path: optional("DB_PATH", "labeler.sqlite"),
cities_path: optional("CITIES_PATH", "data/cities.json"), cities_path: optional("CITIES_PATH", "data/cities.json"),
labeler_url: optional("LABELER_URL", "https://cities-labeler.cosocial.ca"),
}) })
} }
} }