rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5 heartwooda3854ecfb12786c13110cdd7b60880a4ba75971e
{
"request": "trigger",
"version": 1,
"event_type": "patch",
"repository": {
"id": "rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5",
"name": "heartwood",
"description": "Radicle Heartwood Protocol & Stack",
"private": false,
"default_branch": "master",
"delegates": [
"did:key:z6MksFqXN3Yhqk8pTJdUGLwATkRfQvwZXPqR2qMEhbS9wzpT",
"did:key:z6MkireRatUThvd3qzfKht1S44wpm4FEWSSa4PRMTSQZ3voM",
"did:key:z6MkkPvBfjP4bQmco5Dm7UGsX2ruDBieEHi8n9DVJWX5sTEz"
]
},
"action": "Created",
"patch": {
"id": "1dd2ffd30c5bb3854ee4799bf52882e8195cf7d9",
"author": {
"id": "did:key:z6MkknknvqZkuDxZ5DtKqy3Ef11wnWFHByfamcoMdN754CG2",
"alias": "nandi"
},
"title": "Accept URL-encoded RIDs from web UI paste",
"state": {
"status": "archived",
"conflicts": []
},
"before": "4641c342c689dd9c549af6c0235256fe225b53a7",
"after": "a3854ecfb12786c13110cdd7b60880a4ba75971e",
"commits": [
"a3854ecfb12786c13110cdd7b60880a4ba75971e",
"e52942ea1295eadb180033fcd45ff8b684876d54",
"c8c6584f7218690170c376b6745bfd0e194a13fd",
"3947cbb06a2adc443e7964360d482337c6cf49fd",
"0b24223722aa00a1fd206076eccac20f0ef33abc",
"388aa49cef7337e17378fd91256ad5568fa58b46"
],
"target": "7e1cb406bb935c3de008d2171e8961f57b4336ea",
"labels": [],
"assignees": [],
"revisions": [
{
"id": "1dd2ffd30c5bb3854ee4799bf52882e8195cf7d9",
"author": {
"id": "did:key:z6MkknknvqZkuDxZ5DtKqy3Ef11wnWFHByfamcoMdN754CG2",
"alias": "nandi"
},
"description": "The Radicle web UI copies RIDs in percent-encoded form (rad%3A\u2026) from browser URLs. This patch decodes those in RepoId::from_urn so all CLI commands (seed, clone, etc.) accept pasted values without manual conversion.",
"base": "4641c342c689dd9c549af6c0235256fe225b53a7",
"oid": "a3854ecfb12786c13110cdd7b60880a4ba75971e",
"timestamp": 1786202225
}
]
}
}
{
"response": "triggered",
"run_id": {
"id": "ba904a55-5fdf-42f5-99fb-48ae6d760e64"
},
"info_url": "https://cci.rad.levitte.org//ba904a55-5fdf-42f5-99fb-48ae6d760e64.html"
}
Started at: 2026-09-08 22:49:35.908370+02:00
Commands:
$ rad clone rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5 .
✓ Creating checkout in ./...
✓ Remote cloudhead@z6MksFqXN3Yhqk8pTJdUGLwATkRfQvwZXPqR2qMEhbS9wzpT added
✓ Remote-tracking branch cloudhead@z6MksFqXN3Yhqk8pTJdUGLwATkRfQvwZXPqR2qMEhbS9wzpT/master created for z6MksFqXN3Yhqk8pTJdUGLwATkRfQvwZXPqR2qMEhbS9wzpT
✓ Remote fintohaps@z6MkireRatUThvd3qzfKht1S44wpm4FEWSSa4PRMTSQZ3voM added
✓ Remote-tracking branch fintohaps@z6MkireRatUThvd3qzfKht1S44wpm4FEWSSa4PRMTSQZ3voM/master created for z6MkireRatUThvd3qzfKht1S44wpm4FEWSSa4PRMTSQZ3voM
✓ Remote lorenz@z6MkkPvBfjP4bQmco5Dm7UGsX2ruDBieEHi8n9DVJWX5sTEz added
✓ Remote-tracking branch lorenz@z6MkkPvBfjP4bQmco5Dm7UGsX2ruDBieEHi8n9DVJWX5sTEz/master created for z6MkkPvBfjP4bQmco5Dm7UGsX2ruDBieEHi8n9DVJWX5sTEz
✓ Repository successfully cloned under /opt/radcis/ci.rad.levitte.org/cci/state/ba904a55-5fdf-42f5-99fb-48ae6d760e64/w/
╭────────────────────────────────────╮
│ heartwood │
│ Radicle Heartwood Protocol & Stack │
│ 189 issues · 39 patches │
╰────────────────────────────────────╯
Run `cd ./.` to go to the repository directory.
Exit code: 0
$ rad patch checkout 1dd2ffd30c5bb3854ee4799bf52882e8195cf7d9
✓ Switched to branch patch/1dd2ffd at revision 1dd2ffd
✓ Branch patch/1dd2ffd setup to track rad/patches/1dd2ffd30c5bb3854ee4799bf52882e8195cf7d9
Exit code: 0
$ git config advice.detachedHead false
Exit code: 0
$ git checkout a3854ecfb12786c13110cdd7b60880a4ba75971e
HEAD is now at a3854ecf core: Accept URL-encoded RIDs from web UI paste
Exit code: 0
$ rad patch show 1dd2ffd30c5bb3854ee4799bf52882e8195cf7d9 -p
╭───────────────────────────────────────────────────────────────────────────────╮
│ Title Accept URL-encoded RIDs from web UI paste │
│ Patch 1dd2ffd30c5bb3854ee4799bf52882e8195cf7d9 │
│ Author nandi z6Mkknk…N754CG2 │
│ Head a3854ecfb12786c13110cdd7b60880a4ba75971e │
│ Base 4641c342c689dd9c549af6c0235256fe225b53a7 │
│ Target master │
│ Branches patch/1dd2ffd │
│ Commits ahead 6, behind 50 │
│ Status archived │
│ │
│ The Radicle web UI copies RIDs in percent-encoded form (rad%3A…) from browser │
│ URLs. This patch decodes those in RepoId::from_urn so all CLI commands (seed, │
│ clone, etc.) accept pasted values without manual conversion. │
├───────────────────────────────────────────────────────────────────────────────┤
│ a3854ec core: Accept URL-encoded RIDs from web UI paste │
│ e52942e docs: Add did:plc actors plan │
│ c8c6584 cli: Enable radicle plc feature │
│ 3947cbb fetch: Resolve PLC delegates via pinned keys │
│ 0b24223 cli: Link and use did:plc identities │
│ 388aa49 identity: Add did:plc DIDs, pins, and COB actors │
├───────────────────────────────────────────────────────────────────────────────┤
│ ● Revision 1dd2ffd @ 4641c34..a3854ec by nandi z6Mkknk…N754CG2 1 month ago │
╰───────────────────────────────────────────────────────────────────────────────╯
commit a3854ecfb12786c13110cdd7b60880a4ba75971e
Author: nandi <78769380+codegod100@users.noreply.github.com>
Date: Sat Aug 8 08:17:01 2026 -0700
core: Accept URL-encoded RIDs from web UI paste
The Radicle web UI copies RIDs in percent-encoded form (rad%3A…)
from browser URLs. Decode these in RepoId::from_urn so all CLI
commands accept pasted values without manual conversion.
Co-authored-by: Cursor <cursoragent@cursor.com>
diff --git a/crates/radicle-core/src/repo.rs b/crates/radicle-core/src/repo.rs
index 92a700a1a2..b4ae615003 100644
--- a/crates/radicle-core/src/repo.rs
+++ b/crates/radicle-core/src/repo.rs
@@ -39,6 +39,37 @@ fn fmt_mismatched_base_encoding(
)
}
+/// Percent-decode a string when it contains URL-encoded sequences.
+///
+/// This is a no-op when no `%` byte is present.
+fn percent_decode_if_needed(input: &str) -> alloc::borrow::Cow<'_, str> {
+ use alloc::borrow::Cow;
+
+ if !input.as_bytes().contains(&b'%') {
+ return Cow::Borrowed(input);
+ }
+
+ let mut out = String::with_capacity(input.len());
+ let bytes = input.as_bytes();
+ let mut i = 0;
+
+ while i < bytes.len() {
+ if bytes[i] == b'%' && i + 2 < bytes.len() {
+ if let Ok(hex) = core::str::from_utf8(&bytes[i + 1..i + 3]) {
+ if let Ok(byte) = u8::from_str_radix(hex, 16) {
+ out.push(byte as char);
+ i += 3;
+ continue;
+ }
+ }
+ }
+ out.push(bytes[i] as char);
+ i += 1;
+ }
+
+ Cow::Owned(out)
+}
+
/// A repository identifier.
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
@@ -80,7 +111,12 @@ impl RepoId {
/// Parse an identifier from the human-readable URN format.
/// Accepts strings without the prefix [`RAD_PREFIX`] as well,
/// for convenience.
+ ///
+ /// Also accepts URL-encoded URNs as copied from the Radicle web UI,
+ /// e.g. `rad%3Az3XncAdkZjeK9mQS5Sdc4qhw98BUX`.
pub fn from_urn(s: &str) -> Result<Self, IdError> {
+ let s = percent_decode_if_needed(s);
+ let s = s.strip_prefix("rad://").unwrap_or(&s);
let s = s.strip_prefix(RAD_PREFIX).unwrap_or(s);
let id = Self::from_canonical(s)?;
@@ -411,4 +447,18 @@ mod test {
assert!("z3gqcJUoA1n9HaHKufZs5FCSGazv5".parse::<RepoId>().is_ok());
assert!("z3XncAdkZjeK9mQS5Sdc4qhw98BUX".parse::<RepoId>().is_ok());
}
+
+ #[test]
+ fn valid_url_encoded() {
+ assert!(
+ "rad%3Az3gqcJUoA1n9HaHKufZs5FCSGazv5"
+ .parse::<RepoId>()
+ .is_ok()
+ );
+ assert!(
+ "rad%3A%2F%2Fz3gqcJUoA1n9HaHKufZs5FCSGazv5"
+ .parse::<RepoId>()
+ .is_ok()
+ );
+ }
}
commit e52942ea1295eadb180033fcd45ff8b684876d54
Author: nandi <78769380+codegod100@users.noreply.github.com>
Date: Thu Aug 6 22:24:07 2026 -0700
docs: Add did:plc actors plan
Co-authored-by: Cursor <cursoragent@cursor.com>
diff --git a/.cursor/plans/atproto_did_plc_actors_7e2b15da.plan.md b/.cursor/plans/atproto_did_plc_actors_7e2b15da.plan.md
new file mode 100644
index 0000000000..a6773c6df8
--- /dev/null
+++ b/.cursor/plans/atproto_did_plc_actors_7e2b15da.plan.md
@@ -0,0 +1,137 @@
+---
+name: ATProto did:plc actors
+overview: "Add full `did:plc` actor support while keeping NodeId/Noise as device Ed25519 `did:key`. Use hybrid resolution: pin verification keys for delegates, cache for general authorship."
+todos:
+ - id: did-enum
+ content: Replace Did newtype with Key|Plc enum; remove Deref/infallible as_key; fix call sites
+ status: completed
+ - id: plc-resolver
+ content: "Add PlcId, DID-doc parse (Ed25519 #atproto/#radicle), DidResolver + on-disk PLC cache"
+ status: completed
+ - id: embed-delegates
+ content: Pin verifying keys in identity doc for did:plc delegates/allow/canonical; offline verify
+ status: completed
+ - id: actor-did
+ content: Change ActorId to Did; map ExtendedSignature PublicKey → Did via resolver in COB load/authz
+ status: completed
+ - id: cli-remotes
+ content: CLI parse/link/display; follow/remote expansion from PLC → device NIDs
+ status: completed
+ - id: tests-docs
+ content: Unit/CLI tests and short device-vs-PLC design note
+ status: completed
+isProject: false
+---
+
+# ATProto `did:plc` full actors (hybrid)
+
+## Decisions (locked)
+
+- **Scope**: full actors — COB authors, delegates, allow-lists, follow targets can be `did:plc`.
+- **Transport**: `NodeId` / Noise / git namespaces stay device Ed25519 `did:key` ([`crates/radicle-core/src/node.rs`](crates/radicle-core/src/node.rs)).
+- **Resolution**: hybrid — **embed/pin** keys for authz-critical identity (delegates, private allow-lists, canonical rules); **local PLC cache** (fetch from `plc.directory` when online) for general authorship attribution.
+- **Crypto**: Radicle signing stays Ed25519. A `did:plc` is usable only when its DID document exposes an Ed25519 verifying key we accept (prefer `#atproto` if Ed25519; otherwise a `#radicle` Multikey). Non-Ed25519-only PLC accounts are rejected until multi-curve work exists.
+
+## Target model
+
+```mermaid
+flowchart LR
+ subgraph device [Device layer unchanged]
+ Ed25519Key[Ed25519 keypair]
+ NodeId[NodeId / namespaces / Noise]
+ Ed25519Key --> NodeId
+ end
+
+ subgraph identity [Logical identity]
+ DidKey["Did::Key did:key"]
+ DidPlc["Did::Plc did:plc"]
+ Ed25519Key --> DidKey
+ end
+
+ subgraph resolve [Hybrid resolver]
+ Embed[Pinned keys in identity Doc]
+ Cache[Local PLC cache]
+ DidPlc --> Embed
+ DidPlc --> Cache
+ Embed --> VerifyKeys[Ed25519 verifying keys]
+ Cache --> VerifyKeys
+ end
+
+ subgraph cob [COBs]
+ Sig[ExtendedSignature embeds device PublicKey]
+ Actor[ActorId equals Did]
+ Sig --> ResolveMap[map signing key to Did]
+ ResolveMap --> Actor
+ end
+```
+
+## Phase 1 — `Did` sum type
+
+Rewrite [`crates/radicle/src/identity/did.rs`](crates/radicle/src/identity/did.rs):
+
+- Replace `Did(PublicKey)` + `Deref<Target=PublicKey>` with:
+
+```rust
+pub enum Did {
+ Key(PublicKey),
+ Plc(PlcId), // validated did:plc:<24 base32lower>
+}
+```
+
+- Encode/decode both methods; keep serde as string.
+- Remove infallible `as_key()` / `From<Did> for PublicKey`. Add `as_key() -> Option<&PublicKey>` and `verifying_keys(resolver) -> …`.
+- Fix all call sites that assume `Did ≡ PublicKey` (delegates in [`doc.rs`](crates/radicle/src/identity/doc.rs), CLI parse in [`radicle-term` / `radicle-cli` args], `Author::public_key()`, sync that maps `did.as_key()` to `NodeId`, etc.).
+
+## Phase 2 — PLC types + hybrid resolver
+
+New module e.g. [`crates/radicle/src/identity/plc.rs`](crates/radicle/src/identity/plc.rs) (or small `radicle-atproto` crate if deps get heavy):
+
+- `PlcId`, DID document subset (`verificationMethod` Multikey), parse `#atproto` / `#radicle` Ed25519 keys.
+- `trait DidResolver` with `resolve(&Did) -> Result<ResolvedDid, _>` returning verifying keys (+ optional handle / PDS).
+- **Cache backend**: on-disk under profile (e.g. `~/.radicle/cache/plc/`), HTTP GET `https://plc.directory/{did}`, honor cache headers loosely; fail closed when uncached and offline.
+- **Embed backend**: read pinned material from identity `Doc` (see Phase 3); no network.
+
+Wire resolver into profile / storage context so COB load and identity verify can call it.
+
+## Phase 3 — Embed for authz-critical DIDs
+
+Extend identity document so a `did:plc` delegate/allow entry carries pinned verifying material peers can check offline:
+
+- Add a payload or doc-adjacent map, e.g. `xyz.radicle.did` / `verification` keyed by DID → `{ keys: [did:key or multibase], plcCid?: …, asOf?: … }`.
+- `rad id update --delegate did:plc:…` resolves (network), pins Ed25519 keys into the proposal, then proceeds with existing delegate quorum signing (proposers still sign with device keys).
+- `Doc::is_delegate` / `is_visible_to` / canonical `Allowed` compare **DIDs**, not raw keys.
+- Signature verification for identity revisions: signer’s device key must be in the **pinned** key set for that delegate DID (or be a `did:key` delegate as today).
+
+Identity doc version bump / migration: old docs remain `did:key`-only; new docs may mix methods.
+
+## Phase 4 — Actors are DIDs
+
+- Change [`ActorId`](crates/radicle/src/cob/op.rs) from `PublicKey` to `Did`.
+- COB wire format: keep `ExtendedSignature` with device `PublicKey` (no multi-curve). On load, set `Op.author` via resolver: signing key ∈ resolved keys for some known PLC → `Did::Plc`, else `Did::Key(signing_key)`.
+- Update authz in issue/patch/identity COBs (`authorization`, assignees, reactions, merges) to compare `Did`s; “is delegate?” uses doc delegates + pinned keys for “did this device key act for that PLC?”.
+- `Author` already wraps `Did` — drop `public_key()` infallibility; use resolver or `as_key()`.
+
+## Phase 5 — Remotes, follow, CLI surface
+
+- **Remotes / namespaces**: remain `NodeId` (= device key). Adding a remote by `did:plc` resolves cache → current device keys (or explicit `--nid`) and stores device remotes; display can show PLC if bound.
+- **Follow policy**: accept `did:plc`; store follow on the logical DID and expand to device NIDs via cache when applying graph policy.
+- **CLI**: `rad auth` stays device keygen; add bind/link flow (e.g. `rad self --plc` / `rad auth --link-plc`) that resolves PLC, checks local Ed25519 is in `#atproto`/`#radicle`, writes cache + profile binding. Relax “expected `did:key`” in args parsers. Show PLC in `rad self` when linked.
+
+## Phase 6 — Tests and docs
+
+- Unit tests: PLC id parse, DID document Ed25519 extraction, embed pin round-trip, author resolution Key vs Plc, offline delegate verify with pin / fail without.
+- CLI example updates where DIDs appear.
+- Short design note in crate docs: device vs person DID, Ed25519 requirement, hybrid trust (pins for quorum, cache for attribution).
+
+## Out of scope (explicit)
+
+- Changing Noise / `NodeId` to PLC.
+- secp256k1 / P-256 signing inside Radicle.
+- Full PLC operation log verification / rotation-key control plane (trust directory + pins for v1).
+- `did:web` (same resolver trait can grow later).
+
+## Highest-risk touch points
+
+- Removing `Did: Deref<PublicKey>` and `ActorId = PublicKey` will churn most of `radicle` + CLI.
+- Identity doc schema change for pins must stay backward compatible for pure `did:key` repos.
+- Authorship mapping is best-effort from cache: uncached PLC keys attribute as `Did::Key` until resolved (document this; optionally require cache for PLC-labelled display only).
commit c8c6584f7218690170c376b6745bfd0e194a13fd
Author: nandi <78769380+codegod100@users.noreply.github.com>
Date: Thu Aug 6 22:23:36 2026 -0700
cli: Enable radicle plc feature
Co-authored-by: Cursor <cursoragent@cursor.com>
diff --git a/crates/radicle-cli/Cargo.toml b/crates/radicle-cli/Cargo.toml
index 11e77aadde..ac54a81174 100644
--- a/crates/radicle-cli/Cargo.toml
+++ b/crates/radicle-cli/Cargo.toml
@@ -31,7 +31,7 @@ humantime.workspace = true
itertools.workspace = true
log = { workspace = true, features = ["std"] }
nonempty = { workspace = true }
-radicle = { workspace = true, features = ["schemars"] }
+radicle = { workspace = true, features = ["schemars", "plc"] }
radicle-cob = { workspace = true }
radicle-crypto = { workspace = true }
radicle-localtime = { workspace = true }
commit 3947cbb06a2adc443e7964360d482337c6cf49fd
Author: nandi <78769380+codegod100@users.noreply.github.com>
Date: Thu Aug 6 21:37:15 2026 -0700
fetch: Resolve PLC delegates via pinned keys
Co-authored-by: Cursor <cursoragent@cursor.com>
diff --git a/crates/radicle-fetch/src/state.rs b/crates/radicle-fetch/src/state.rs
index bcec3c0d8c..22bd9f7e76 100644
--- a/crates/radicle-fetch/src/state.rs
+++ b/crates/radicle-fetch/src/state.rs
@@ -395,14 +395,14 @@ impl FetchState {
.canonical()?
.ok_or(error::Protocol::MissingRadId)?;
- let is_delegate = anchor.is_delegate(&Did::from(handle.local()));
+ let is_delegate = anchor.is_delegate(&Did::from(handle.local()))
+ || anchor.is_delegate_key(handle.local());
// TODO: not sure we should allow to block *any* peer from the
// delegate set. We could end up ignoring delegates.
let delegates = anchor
- .delegates()
- .iter()
+ .delegate_keys()
+ .into_iter()
.filter(|id| !handle.is_blocked(id))
- .map(|did| PublicKey::from(*did))
.collect::<BTreeSet<_>>();
log::trace!("Identity delegates {delegates:?}");
commit 0b24223722aa00a1fd206076eccac20f0ef33abc
Author: nandi <78769380+codegod100@users.noreply.github.com>
Date: Thu Aug 6 21:37:15 2026 -0700
cli: Link and use did:plc identities
Co-authored-by: Cursor <cursoragent@cursor.com>
diff --git a/crates/radicle-cli/src/commands/auth.rs b/crates/radicle-cli/src/commands/auth.rs
index 4caed9f02d..47ec3c81da 100644
--- a/crates/radicle-cli/src/commands/auth.rs
+++ b/crates/radicle-cli/src/commands/auth.rs
@@ -15,8 +15,37 @@ pub use args::Args;
pub fn run(args: Args, ctx: impl term::Context) -> anyhow::Result<()> {
match ctx.profile() {
- Ok(profile) => authenticate(args, &profile),
- Err(_) => init(args),
+ Ok(profile) => {
+ if let Some(plc) = args.link_plc {
+ return link_plc(&profile, plc);
+ }
+ authenticate(args, &profile)
+ }
+ Err(_) => {
+ if args.link_plc.is_some() {
+ anyhow::bail!("initialize a profile with `rad auth` before linking a PLC DID");
+ }
+ init(args)
+ }
+ }
+}
+
+fn link_plc(profile: &Profile, plc: radicle::identity::PlcId) -> anyhow::Result<()> {
+ let spinner = term::spinner(format!("Linking device to {plc}…"));
+ match profile.link_plc(plc) {
+ Ok(()) => {
+ spinner.finish();
+ term::success!(
+ "Linked {}. Device DID remains {}.",
+ term::format::highlight(plc),
+ term::format::tertiary(profile.did())
+ );
+ Ok(())
+ }
+ Err(e) => {
+ spinner.failed();
+ Err(e.into())
+ }
}
}
diff --git a/crates/radicle-cli/src/commands/auth/args.rs b/crates/radicle-cli/src/commands/auth/args.rs
index 94944ba6fe..da922c360c 100644
--- a/crates/radicle-cli/src/commands/auth/args.rs
+++ b/crates/radicle-cli/src/commands/auth/args.rs
@@ -1,4 +1,5 @@
use clap::Parser;
+use radicle::identity::PlcId;
use radicle::node::Alias;
const ABOUT: &str = "Manage identities and profiles";
@@ -6,6 +7,10 @@ const LONG_ABOUT: &str = r#"
A passphrase may be given via the environment variable `RAD_PASSPHRASE` or
via the standard input stream if `--stdin` is used. Using either of these
methods disables the passphrase prompt.
+
+Link an ATProto PLC DID to this device with `--link-plc did:plc:…`. The local
+Ed25519 key must appear as a Multikey `#atproto` or `#radicle` verification
+method on that DID document.
"#;
#[derive(Debug, Parser)]
@@ -18,4 +23,8 @@ pub struct Args {
/// Read passphrase from stdin
#[arg(long, default_value_t = false)]
pub stdin: bool,
+
+ /// Link this device to an ATProto `did:plc` identity
+ #[arg(long, value_name = "DID")]
+ pub link_plc: Option<PlcId>,
}
diff --git a/crates/radicle-cli/src/commands/checkout.rs b/crates/radicle-cli/src/commands/checkout.rs
index 3b8a1b1f5f..7ebbf00d25 100644
--- a/crates/radicle-cli/src/commands/checkout.rs
+++ b/crates/radicle-cli/src/commands/checkout.rs
@@ -24,7 +24,12 @@ pub fn run(args: Args, ctx: impl term::Context) -> anyhow::Result<()> {
fn execute(args: Args, profile: &Profile) -> anyhow::Result<PathBuf> {
let storage = &profile.storage;
- let remote = args.remote.unwrap_or(profile.did());
+ let remote = match args.remote {
+ Some(did) => *did
+ .as_key()
+ .ok_or_else(|| anyhow!("checkout remote must be a did:key, got {did}"))?,
+ None => *profile.id(),
+ };
let doc = storage
.repository(args.repo)?
.identity_doc()
@@ -58,7 +63,7 @@ fn execute(args: Args, profile: &Profile) -> anyhow::Result<PathBuf> {
.delegates()
.clone()
.into_iter()
- .map(|did| *did)
+ .filter_map(|did| did.as_key().copied())
.filter(|id| id != profile.id())
.collect::<Vec<_>>();
diff --git a/crates/radicle-cli/src/commands/clone.rs b/crates/radicle-cli/src/commands/clone.rs
index e0adfdce00..20d43ddf6a 100644
--- a/crates/radicle-cli/src/commands/clone.rs
+++ b/crates/radicle-cli/src/commands/clone.rs
@@ -57,7 +57,7 @@ pub fn run(args: Args, ctx: impl term::Context) -> anyhow::Result<()> {
let delegates = doc
.delegates()
.iter()
- .map(|d| **d)
+ .filter_map(|d| d.as_key().copied())
.filter(|id| id != profile.id())
.collect::<Vec<_>>();
let default_branch = proj.default_branch().clone();
diff --git a/crates/radicle-cli/src/commands/follow.rs b/crates/radicle-cli/src/commands/follow.rs
index 194046b889..9e2c0ce699 100644
--- a/crates/radicle-cli/src/commands/follow.rs
+++ b/crates/radicle-cli/src/commands/follow.rs
@@ -1,5 +1,6 @@
mod args;
+use radicle::identity::DidResolver;
use radicle::node::{Alias, AliasStore, Handle, NodeId, policy};
use radicle::{Node, prelude::*};
use radicle_term::{Element as _, Paint, Table};
@@ -14,7 +15,7 @@ pub fn run(args: Args, ctx: impl term::Context) -> anyhow::Result<()> {
let mut node = radicle::Node::new(profile.socket_from_env());
match Operation::from(args) {
- Operation::Follow { nid, alias, .. } => follow(nid, alias, &mut node, &profile)?,
+ Operation::Follow { target, alias, .. } => follow(target, alias, &mut node, &profile)?,
Operation::List { alias, .. } => following(&profile, alias)?,
}
@@ -22,6 +23,32 @@ pub fn run(args: Args, ctx: impl term::Context) -> anyhow::Result<()> {
}
pub fn follow(
+ target: Did,
+ alias: Option<Alias>,
+ node: &mut Node,
+ profile: &Profile,
+) -> Result<(), anyhow::Error> {
+ let nids = match target {
+ Did::Key(nid) => vec![nid],
+ Did::Plc(_) => {
+ let resolved = profile.did_resolver().resolve(&target)?;
+ if resolved.keys.is_empty() {
+ anyhow::bail!(
+ "no Ed25519 verifying keys for {target}; cache the DID document first"
+ );
+ }
+ resolved.keys
+ }
+ };
+
+ for nid in nids {
+ follow_nid(nid, alias.clone(), node, profile)?;
+ }
+
+ Ok(())
+}
+
+fn follow_nid(
nid: NodeId,
alias: Option<Alias>,
node: &mut Node,
@@ -98,14 +125,15 @@ fn push_policies(
]);
}
Err(err) => {
- term::error(format!("Failed to read a follow policy: {err}"));
+ term::error(format!("error reading follow policy: {err}"));
}
}
}
}
-fn fallback_alias(nid: &PublicKey, aliases: &impl AliasStore) -> String {
+fn fallback_alias(nid: &NodeId, aliases: &impl AliasStore) -> String {
aliases
.alias(nid)
- .map_or("n/a".to_string(), |alias| alias.to_string())
+ .map(|a| a.to_string())
+ .unwrap_or_default()
}
diff --git a/crates/radicle-cli/src/commands/follow/args.rs b/crates/radicle-cli/src/commands/follow/args.rs
index 35860f5c2b..8d0555605f 100644
--- a/crates/radicle-cli/src/commands/follow/args.rs
+++ b/crates/radicle-cli/src/commands/follow/args.rs
@@ -1,6 +1,7 @@
use clap::Parser;
-use radicle::node::{Alias, NodeId};
+use radicle::node::Alias;
+use radicle::prelude::Did;
use crate::terminal as term;
@@ -8,17 +9,18 @@ const ABOUT: &str = "Manage node follow policies";
const LONG_ABOUT: &str = r#"
The `follow` command will print all nodes being followed, optionally filtered by alias, if no
-Node ID is provided.
-Otherwise, it takes a Node ID, optionally in DID format, and updates the follow policy
-for that peer, optionally giving the peer the alias provided.
+DID or Node ID is provided.
+Otherwise, it takes a Node ID, `did:key`, or `did:plc`, and updates the follow policy
+for that peer (PLC expands to verifying device keys from the local cache), optionally
+giving the peer the alias provided.
"#;
#[derive(Parser, Debug)]
#[command(about = ABOUT, long_about = LONG_ABOUT, disable_version_flag = true)]
pub struct Args {
- /// The DID or Node ID of the peer to follow
- #[arg(value_parser = term::args::parse_nid)]
- nid: Option<NodeId>,
+ /// The DID (`did:key` / `did:plc`) or Node ID of the peer to follow
+ #[arg(value_parser = term::args::parse_did_or_nid)]
+ target: Option<Did>,
/// Associate an alias to a followed peer
#[arg(long)]
@@ -31,7 +33,7 @@ pub struct Args {
pub(super) enum Operation {
Follow {
- nid: NodeId,
+ target: Did,
alias: Option<Alias>,
#[allow(dead_code)]
verbose: bool,
@@ -46,14 +48,14 @@ pub(super) enum Operation {
impl From<Args> for Operation {
fn from(
Args {
- nid,
+ target,
alias,
verbose,
}: Args,
) -> Self {
- match nid {
- Some(nid) => Self::Follow {
- nid,
+ match target {
+ Some(target) => Self::Follow {
+ target,
alias,
verbose,
},
diff --git a/crates/radicle-cli/src/commands/id.rs b/crates/radicle-cli/src/commands/id.rs
index 9ff38f2bbf..abdc4667a4 100644
--- a/crates/radicle-cli/src/commands/id.rs
+++ b/crates/radicle-cli/src/commands/id.rs
@@ -7,7 +7,7 @@ use anyhow::{Context, anyhow};
use radicle::cob::Title;
use radicle::cob::identity::{self, IdentityMut, Revision, RevisionId};
use radicle::identity::doc::update;
-use radicle::identity::{Doc, Identity, RawDoc, doc};
+use radicle::identity::{DidResolver, Doc, Identity, RawDoc, doc};
use radicle::node::NodeId;
use radicle::storage::{ReadRepository as _, ReadStorage as _, WriteRepository};
use radicle::{Profile, cob, crypto};
@@ -137,7 +137,8 @@ pub fn run(args: Args, ctx: impl term::Context) -> anyhow::Result<()> {
}
};
let threshold = proposal.threshold;
- let proposal = match update::delegates(proposal, delegates, rescind, &repo)? {
+ let proposal = match update::delegates(proposal, delegates.clone(), rescind, &repo)?
+ {
Ok(proposal) => proposal,
Err(errs) => {
term::error(format!("failed to verify delegates for {rid}"));
@@ -151,6 +152,20 @@ pub fn run(args: Args, ctx: impl term::Context) -> anyhow::Result<()> {
}
};
+ // Pin verifying keys for any new `did:plc` delegates.
+ let mut proposal = proposal;
+ for did in &delegates {
+ if did.is_plc() {
+ let keys = profile.did_resolver().resolve(did)?.keys;
+ if keys.is_empty() {
+ anyhow::bail!(
+ "no Ed25519 keys for {did}; cache the DID document before adding as delegate"
+ );
+ }
+ proposal.pin_did(*did, keys)?;
+ }
+ }
+
// TODO(erikli): whenever `clap` starts supporting custom value parsers
// for a series of values, we can parse into `Payload` implicitly.
let payloads = args::parse_many_upserts(&payload).collect::<Result<Vec<_>, _>>()?;
@@ -248,7 +263,7 @@ pub fn run(args: Args, ctx: impl term::Context) -> anyhow::Result<()> {
};
let title = term::label(r.title.to_string());
let (alias, author) =
- term::format::Author::new(r.author.public_key(), &profile, true).labels();
+ term::format::Author::new(r.author.id, &profile, true).labels();
let timestamp = term::format::timestamp(r.timestamp).into();
let parent = r
.parent
@@ -431,7 +446,7 @@ fn print_meta(revision: &Revision, previous: &Doc, profile: &Profile) -> anyhow:
let mut signatures = term::Table::<4, _>::default();
for id in accepted {
- let author = term::format::Author::new(&id, profile, true);
+ let author = term::format::Author::new(id, profile, true);
signatures.push([
term::PREFIX_SUCCESS.into(),
id.to_string().into(),
@@ -440,7 +455,7 @@ fn print_meta(revision: &Revision, previous: &Doc, profile: &Profile) -> anyhow:
]);
}
for id in rejected {
- let author = term::format::Author::new(&id, profile, true);
+ let author = term::format::Author::new(id, profile, true);
signatures.push([
term::PREFIX_ERROR.into(),
id.to_string().into(),
@@ -449,7 +464,7 @@ fn print_meta(revision: &Revision, previous: &Doc, profile: &Profile) -> anyhow:
]);
}
for id in unknown {
- let author = term::format::Author::new(id, profile, true);
+ let author = term::format::Author::new(*id, profile, true);
signatures.push([
term::format::dim("?").into(),
id.to_string().into(),
@@ -545,8 +560,7 @@ fn on_identity_err(e: identity::Error, profile: &Profile) -> anyhow::Error {
fn on_apply_err(e: &identity::ApplyError, profile: &Profile) -> anyhow::Error {
match e {
e @ identity::ApplyError::NonDelegateUnauthorized { author, .. } => {
- let nid = NodeId::from(*author);
- let labels = Author::new(&nid, profile, false).labels();
+ let labels = Author::new(*author, profile, false).labels();
Error::with_hint(
anyhow!(e.to_string()),
diff --git a/crates/radicle-cli/src/commands/inspect.rs b/crates/radicle-cli/src/commands/inspect.rs
index 6cd54677ff..ddafe1491c 100644
--- a/crates/radicle-cli/src/commands/inspect.rs
+++ b/crates/radicle-cli/src/commands/inspect.rs
@@ -131,7 +131,9 @@ pub fn run(args: Args, ctx: impl term::Context) -> anyhow::Result<()> {
let (_, doc) = repo(rid, storage)?;
let aliases = profile.aliases();
for did in doc.delegates().iter() {
- if let Some(alias) = aliases.alias(did) {
+ if let Some(key) = did.as_key()
+ && let Some(alias) = aliases.alias(key)
+ {
term::println(format_args!(
"{} {}",
term::format::tertiary(&did),
diff --git a/crates/radicle-cli/src/commands/issue.rs b/crates/radicle-cli/src/commands/issue.rs
index dc42414b76..5f754cc10c 100644
--- a/crates/radicle-cli/src/commands/issue.rs
+++ b/crates/radicle-cli/src/commands/issue.rs
@@ -244,8 +244,8 @@ where
}
let assignee = match assigned {
- Some(Assigned::Me) => Some(*profile.id()),
- Some(Assigned::Peer(id)) => Some((*id).into()),
+ Some(Assigned::Me) => Some(Did::from(*profile.id())),
+ Some(Assigned::Peer(id)) => Some(*id),
None => None,
};
@@ -262,7 +262,7 @@ where
};
if let Some(a) = assignee
- && !issue.assignees().any(|v| v == &Did::from(a))
+ && !issue.assignees().any(|v| *v == a)
{
return None;
}
@@ -301,7 +301,7 @@ where
let assigned: String = issue
.assignees()
.map(|did| {
- let (alias, _) = Author::new(did.as_key(), profile, verbose).labels();
+ let (alias, _) = Author::new(*did, profile, verbose).labels();
alias.content().to_owned()
})
@@ -312,7 +312,7 @@ where
labels.sort();
let author = issue.author().id;
- let (alias, did) = Author::new(&author, profile, verbose).labels();
+ let (alias, did) = Author::new(author, profile, verbose).labels();
mk_issue_row(id, issue, assigned, labels, alias, did)
}));
diff --git a/crates/radicle-cli/src/commands/patch/list.rs b/crates/radicle-cli/src/commands/patch/list.rs
index ef374b8a8f..66ea1d088b 100644
--- a/crates/radicle-cli/src/commands/patch/list.rs
+++ b/crates/radicle-cli/src/commands/patch/list.rs
@@ -71,9 +71,9 @@ pub fn run(
]);
table.divider();
- let me = *profile.id();
+ let me = Did::from(*profile.id());
all.sort_by(|(id1, p1), (id2, p2)| {
- let is_me = (p2.author().id().as_key() == &me).cmp(&(p1.author().id().as_key() == &me));
+ let is_me = (p2.author().id() == &me).cmp(&(p1.author().id() == &me));
let by_id = id1.cmp(id2);
let by_rev_time = p2.updated_at().cmp(&p1.updated_at());
@@ -111,11 +111,11 @@ pub fn row(
let (from, to) = revision.range();
let stats = common::diff_stats(repository.raw(), &from, &to)?;
let author = patch.author().id;
- let (alias, did) = Author::new(&author, profile, false).labels();
+ let (alias, did) = Author::new(author, profile, false).labels();
let mut delegates = repository
.delegates()?
.into_iter()
- .map(|delegate| (*delegate, term::format::patch::verdict(None)))
+ .map(|delegate| (delegate, term::format::patch::verdict(None)))
.collect::<BTreeMap<_, _>>();
for (key, review) in revision.reviews() {
delegates
diff --git a/crates/radicle-cli/src/commands/patch/review/builder.rs b/crates/radicle-cli/src/commands/patch/review/builder.rs
index 4b48d26689..597fa3a8ab 100644
--- a/crates/radicle-cli/src/commands/patch/review/builder.rs
+++ b/crates/radicle-cli/src/commands/patch/review/builder.rs
@@ -652,7 +652,7 @@ impl<'a> ReviewBuilder<'a> {
return Ok(());
}
- let review = if let Some(r) = revision.review_by(signer.public_key()) {
+ let review = if let Some(r) = revision.review_by(&Did::from(*signer.public_key())) {
r.id()
} else {
patch.review(
diff --git a/crates/radicle-cli/src/commands/remote/add.rs b/crates/radicle-cli/src/commands/remote/add.rs
index 0a7f27f5e5..38cc56cb38 100644
--- a/crates/radicle-cli/src/commands/remote/add.rs
+++ b/crates/radicle-cli/src/commands/remote/add.rs
@@ -27,7 +27,7 @@ pub fn run(
if !profile.policies()?.is_following(nid)? {
let alias = name.as_ref().and_then(|n| Alias::from_str(n.as_str()).ok());
- follow::follow(*nid, alias, &mut node, profile)?;
+ follow::follow(Did::from(*nid), alias, &mut node, profile)?;
sync::fetch(
rid,
SyncSettings::default().with_profile(profile),
diff --git a/crates/radicle-cli/src/commands/self.rs b/crates/radicle-cli/src/commands/self.rs
index 11a77ac19f..c8e10ddd17 100644
--- a/crates/radicle-cli/src/commands/self.rs
+++ b/crates/radicle-cli/src/commands/self.rs
@@ -15,6 +15,13 @@ pub fn run(args: Args, ctx: impl term::Context) -> anyhow::Result<()> {
if args.did {
term::println(profile.did());
+ } else if args.plc {
+ match profile.plc() {
+ Some(plc) => term::println(plc),
+ None => anyhow::bail!(
+ "no PLC DID linked; run `rad auth --link-plc did:plc:…` after caching the document"
+ ),
+ }
} else if args.alias {
term::println(profile.config.alias());
} else if args.home {
@@ -54,6 +61,13 @@ fn all(profile: &Profile) -> anyhow::Result<()> {
term::format::tertiary(did).into(),
]);
+ if let Some(plc) = profile.plc() {
+ table.push([
+ term::format::style("PLC").into(),
+ term::format::tertiary(plc).into(),
+ ]);
+ }
+
let socket = profile.socket_from_env();
let node = if Node::new(&socket).is_running() {
term::format::positive(format!("running ({})", socket.display()))
diff --git a/crates/radicle-cli/src/commands/self/args.rs b/crates/radicle-cli/src/commands/self/args.rs
index f4c9c8cb76..16f081efdb 100644
--- a/crates/radicle-cli/src/commands/self/args.rs
+++ b/crates/radicle-cli/src/commands/self/args.rs
@@ -9,6 +9,9 @@ pub struct Args {
/// Show your DID
#[arg(long)]
pub(super) did: bool,
+ /// Show your linked ATProto PLC DID, if any
+ #[arg(long)]
+ pub(super) plc: bool,
/// Show your Node alias
#[arg(long)]
pub(super) alias: bool,
diff --git a/crates/radicle-cli/src/commands/sync.rs b/crates/radicle-cli/src/commands/sync.rs
index 9767298195..05479e62ff 100644
--- a/crates/radicle-cli/src/commands/sync.rs
+++ b/crates/radicle-cli/src/commands/sync.rs
@@ -85,7 +85,7 @@ fn sync_status(
const SYMBOL_STATE_UNKNOWN: &str = "•";
let mut table = Table::<5, term::Label>::new(TableOptions::bordered());
- let mut seeds: Vec<_> = node.seeds_for(rid, [*profile.did()])?.into();
+ let mut seeds: Vec<_> = node.seeds_for(rid, [*profile.id()])?.into();
let local_nid = node.nid()?;
let aliases = profile.aliases();
@@ -263,7 +263,7 @@ pub fn fetch(
None => {
// We push nodes that are in our seed list in attempt to fulfill the
// replicas, if needed.
- let seeds = node.seeds_for(rid, [*profile.did()])?;
+ let seeds = node.seeds_for(rid, [*profile.id()])?;
let (connected, disconnected) = seeds.partition();
let candidates = connected
.into_iter()
diff --git a/crates/radicle-cli/src/node.rs b/crates/radicle-cli/src/node.rs
index 5165c5233c..a29b75c9da 100644
--- a/crates/radicle-cli/src/node.rs
+++ b/crates/radicle-cli/src/node.rs
@@ -216,7 +216,7 @@ where
match node.announce(
rid,
- [profile.did().into()],
+ [*profile.id()],
settings.timeout,
announcer,
|node, progress| {
diff --git a/crates/radicle-cli/src/terminal/args.rs b/crates/radicle-cli/src/terminal/args.rs
index d47b31405d..39042102ca 100644
--- a/crates/radicle-cli/src/terminal/args.rs
+++ b/crates/radicle-cli/src/terminal/args.rs
@@ -73,11 +73,28 @@ pub(crate) struct NodeIdParseError {
}
pub(crate) fn parse_nid(value: &str) -> Result<NodeId, NodeIdParseError> {
- value.parse::<Did>().map(NodeId::from).or_else(|did| {
- value
- .parse::<NodeId>()
- .map_err(|nid| NodeIdParseError { nid, did })
- })
+ value
+ .parse::<Did>()
+ .and_then(NodeId::try_from)
+ .or_else(|did| {
+ value
+ .parse::<NodeId>()
+ .map_err(|nid| NodeIdParseError { nid, did })
+ })
+}
+
+/// Parse a `did:key`, `did:plc`, or bare Node ID into a [`Did`].
+pub(crate) fn parse_did_or_nid(value: &str) -> Result<Did, NodeIdParseError> {
+ if let Ok(did) = value.parse::<Did>() {
+ return Ok(did);
+ }
+ value
+ .parse::<NodeId>()
+ .map(Did::from)
+ .map_err(|nid| NodeIdParseError {
+ nid,
+ did: radicle::identity::did::DidError::Did(value.to_owned()),
+ })
}
#[derive(Clone, Debug)]
diff --git a/crates/radicle-cli/src/terminal/comment.rs b/crates/radicle-cli/src/terminal/comment.rs
index 8791a27951..40fbd35b06 100644
--- a/crates/radicle-cli/src/terminal/comment.rs
+++ b/crates/radicle-cli/src/terminal/comment.rs
@@ -11,7 +11,7 @@ pub fn header<T>(
profile: &Profile,
) -> term::hstack::HStack<'static> {
let author = comment.author();
- let author = Author::new(author, profile, false);
+ let author = Author::new(*author, profile, false);
let (alias, nid) = author.labels();
term::hstack::HStack::default()
diff --git a/crates/radicle-cli/src/terminal/format.rs b/crates/radicle-cli/src/terminal/format.rs
index e3e0773962..c676ba8dbf 100644
--- a/crates/radicle-cli/src/terminal/format.rs
+++ b/crates/radicle-cli/src/terminal/format.rs
@@ -94,8 +94,16 @@ pub fn cob(id: &ObjectId) -> Paint<String> {
/// Format a DID.
#[must_use]
pub fn did(did: &Did) -> Paint<String> {
- let nid = did.as_key().to_human();
- Paint::new(format!("{}…{}", &nid[..7], &nid[nid.len() - 7..]))
+ match did {
+ Did::Key(key) => {
+ let nid = key.to_human();
+ Paint::new(format!("{}…{}", &nid[..7], &nid[nid.len() - 7..]))
+ }
+ Did::Plc(id) => {
+ let s = id.encode();
+ Paint::new(format!("{}…{}", &s[..12], &s[s.len() - 6..]))
+ }
+ }
}
/// Format a Visibility.
@@ -242,23 +250,30 @@ impl fmt::Display for Identity<'_> {
}
}
-/// This enum renders (nid, alias) in terminal depending on user variant.
-pub struct Author<'a> {
- nid: &'a NodeId,
+/// This enum renders (did, alias) in terminal depending on user variant.
+pub struct Author {
+ did: Did,
alias: Option<Alias>,
you: bool,
verbose: bool,
}
-impl<'a> Author<'a> {
+impl Author {
#[must_use]
- pub fn new(nid: &'a NodeId, profile: &Profile, verbose: bool) -> Author<'a> {
- let alias = profile.alias(nid);
+ pub fn new(id: impl Into<Did>, profile: &Profile, verbose: bool) -> Author {
+ let did = id.into();
+ let (alias, you) = match did.as_key() {
+ Some(nid) => (profile.alias(nid), nid == profile.id()),
+ None => (
+ None,
+ profile.plc().is_some_and(|plc| Did::from(plc) == did),
+ ),
+ };
Self {
- nid,
+ did,
alias,
- you: nid == profile.id(),
+ you,
verbose,
}
}
@@ -277,6 +292,26 @@ impl<'a> Author<'a> {
}
}
+ fn display_id(&self) -> Paint<String> {
+ match self.did {
+ Did::Key(key) => {
+ if self.verbose {
+ term::format::node_id_human(&key)
+ } else {
+ term::format::node_id_human_compact(&key)
+ }
+ }
+ Did::Plc(id) => {
+ let s = id.encode();
+ if self.verbose {
+ Paint::new(s)
+ } else {
+ Paint::new(format!("{}…{}", &s[..12], &s[s.len() - 6..]))
+ }
+ }
+ }
+ }
+
/// Get the labels of the `Author`. The labels can take the following forms:
///
/// * `(<alias>, (you))` -- the `Author` is the local peer and has an alias
@@ -285,11 +320,7 @@ impl<'a> Author<'a> {
/// * `(<blank>, <did>)` -- the `Author` is another peer and has no alias
#[must_use]
pub fn labels(self) -> (term::Label, term::Label) {
- let node_id = if self.verbose {
- term::format::node_id_human(self.nid)
- } else {
- term::format::node_id_human_compact(self.nid)
- };
+ let node_id = self.display_id();
let alias = match self.alias.as_ref() {
Some(alias) => term::format::primary(alias).into(),
diff --git a/crates/radicle-cli/src/terminal/issue.rs b/crates/radicle-cli/src/terminal/issue.rs
index 94410f11d9..baebf2a6b3 100644
--- a/crates/radicle-cli/src/terminal/issue.rs
+++ b/crates/radicle-cli/src/terminal/issue.rs
@@ -52,7 +52,7 @@ pub fn show(
.collect();
let author = issue.author();
let did = author.id();
- let author = Author::new(did, profile, verbose);
+ let author = Author::new(*did, profile, verbose);
let mut attrs = Table::<2, term::Line>::new(TableOptions {
spacing: 2,
diff --git a/crates/radicle-cli/src/terminal/patch.rs b/crates/radicle-cli/src/terminal/patch.rs
index aca262abbe..7da53dd2b8 100644
--- a/crates/radicle-cli/src/terminal/patch.rs
+++ b/crates/radicle-cli/src/terminal/patch.rs
@@ -368,7 +368,7 @@ pub fn show(
let ahead_behind =
common::ahead_behind(stored.raw(), revision.head(), patch.target().head(stored)?)?;
let author = patch.author();
- let author = term::format::Author::new(author.id(), profile, verbose);
+ let author = term::format::Author::new(*author.id(), profile, verbose);
let labels = patch.labels().map(|l| l.to_string()).collect::<Vec<_>>();
let doc = stored.identity_doc()?;
diff --git a/crates/radicle-cli/src/terminal/patch/timeline.rs b/crates/radicle-cli/src/terminal/patch/timeline.rs
index c4250bcd3c..7fd301effd 100644
--- a/crates/radicle-cli/src/terminal/patch/timeline.rs
+++ b/crates/radicle-cli/src/terminal/patch/timeline.rs
@@ -44,11 +44,11 @@ pub fn timeline<'a>(
/// We do not distinguish between revisions created by the original author and
/// others, and also not between the initial revision and others. This tends to
/// confuse more than it helps.
-struct RevisionEntry<'a> {
+struct RevisionEntry {
/// Whether this entry is about the initial [`Revision`] of the patch.
is_initial: bool,
/// The [`Author`] that created the [`Revision`].
- author: Author<'a>,
+ author: Author,
/// When the [`Revision`] was created.
timestamp: cob::Timestamp,
/// The id of the [`Revision`].
@@ -58,14 +58,14 @@ struct RevisionEntry<'a> {
/// The head commit of the [`Revision`].
head: git::Oid,
/// All [`Update`]s that occurred on the [`Revision`].
- updates: Vec<Update<'a>>,
+ updates: Vec<Update>,
}
-impl<'a> RevisionEntry<'a> {
+impl RevisionEntry {
fn from_revision(
- patch: &'a Patch,
+ patch: &Patch,
id: RevisionId,
- revision: &'a Revision,
+ revision: &Revision,
profile: &Profile,
verbose: bool,
) -> Self {
@@ -83,7 +83,7 @@ impl<'a> RevisionEntry<'a> {
Some((
merge.timestamp,
Update::Merged {
- author: Author::new(nid, profile, verbose),
+ author: Author::new(*nid, profile, verbose),
merge: if merge.commit != revision.head() {
Some(merge.clone())
} else {
@@ -99,7 +99,7 @@ impl<'a> RevisionEntry<'a> {
RevisionEntry {
is_initial: patch.root().0 == id,
- author: Author::new(&revision.author().id, profile, verbose),
+ author: Author::new(revision.author().id, profile, verbose),
timestamp: revision.timestamp(),
id,
base: *revision.base(),
@@ -110,9 +110,9 @@ impl<'a> RevisionEntry<'a> {
fn into_lines(
self,
- profile: &'a Profile,
+ profile: &Profile,
verbose: bool,
- ) -> impl Iterator<Item = term::Line> + 'a {
+ ) -> impl Iterator<Item = term::Line> + '_ {
use term::{format::*, *};
let id: Label = if verbose {
@@ -155,18 +155,18 @@ impl<'a> RevisionEntry<'a> {
}
/// An update in the [`Patch`]'s timeline.
-enum Update<'a> {
+enum Update {
/// A revision of the patch was reviewed.
Reviewed { review: Review },
/// A revision of the patch was merged.
Merged {
- author: Author<'a>,
+ author: Author,
/// If the merge is none, this means that it was a fast-forward merge.
merge: Option<Merge>,
},
}
-impl Update<'_> {
+impl Update {
fn into_line(self, profile: &Profile, verbose: bool) -> term::Line {
use term::{format::*, *};
@@ -182,7 +182,7 @@ impl Update<'_> {
Line::spaced([symbol.into(), verb.into(), dim(by).into()])
.space()
- .extend(Author::new(&review.author().id.into(), profile, verbose).line())
+ .extend(Author::new(review.author().id, profile, verbose).line())
.space()
.item(dim(timestamp(review.timestamp())))
}
diff --git a/crates/radicle-term/src/args.rs b/crates/radicle-term/src/args.rs
index f178f96717..c46f27558a 100644
--- a/crates/radicle-term/src/args.rs
+++ b/crates/radicle-term/src/args.rs
@@ -77,7 +77,10 @@ pub fn did(val: &OsString) -> anyhow::Result<Did> {
if crypto::PublicKey::from_str(&val).is_ok() {
return Err(anyhow!("expected DID, did you mean 'did:key:{val}'?"));
} else {
- return Err(anyhow!("invalid DID '{}', expected 'did:key'", val));
+ return Err(anyhow!(
+ "invalid DID '{}', expected 'did:key:…' or 'did:plc:…'",
+ val
+ ));
}
};
Ok(peer)
commit 388aa49cef7337e17378fd91256ad5568fa58b46
Author: nandi <78769380+codegod100@users.noreply.github.com>
Date: Thu Aug 6 21:37:15 2026 -0700
identity: Add did:plc DIDs, pins, and COB actors
Co-authored-by: Cursor <cursoragent@cursor.com>
diff --git a/Cargo.lock b/Cargo.lock
index 900ecf434a..408df28fe9 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2830,6 +2830,7 @@ dependencies = [
"thiserror 2.0.18",
"uds_windows",
"unicode-normalization",
+ "ureq",
]
[[package]]
@@ -3331,6 +3332,20 @@ dependencies = [
"subtle",
]
+[[package]]
+name = "ring"
+version = "0.17.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
+dependencies = [
+ "cc",
+ "cfg-if",
+ "getrandom 0.2.17",
+ "libc",
+ "untrusted",
+ "windows-sys 0.52.0",
+]
+
[[package]]
name = "rsa"
version = "0.9.10"
@@ -3386,6 +3401,41 @@ dependencies = [
"windows-sys 0.61.2",
]
+[[package]]
+name = "rustls"
+version = "0.23.43"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
+dependencies = [
+ "log",
+ "once_cell",
+ "ring",
+ "rustls-pki-types",
+ "rustls-webpki",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-pki-types"
+version = "1.15.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
+dependencies = [
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-webpki"
+version = "0.103.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
+dependencies = [
+ "ring",
+ "rustls-pki-types",
+ "untrusted",
+]
+
[[package]]
name = "rustversion"
version = "1.0.22"
@@ -4522,6 +4572,27 @@ dependencies = [
"subtle",
]
+[[package]]
+name = "untrusted"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
+
+[[package]]
+name = "ureq"
+version = "2.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d"
+dependencies = [
+ "base64 0.22.1",
+ "log",
+ "once_cell",
+ "rustls",
+ "rustls-pki-types",
+ "url",
+ "webpki-roots 0.26.11",
+]
+
[[package]]
name = "url"
version = "2.5.8"
@@ -4718,6 +4789,24 @@ dependencies = [
"wasm-bindgen",
]
+[[package]]
+name = "webpki-roots"
+version = "0.26.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
+dependencies = [
+ "webpki-roots 1.0.9",
+]
+
+[[package]]
+name = "webpki-roots"
+version = "1.0.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
+dependencies = [
+ "rustls-pki-types",
+]
+
[[package]]
name = "winapi"
version = "0.3.9"
diff --git a/crates/radicle/Cargo.toml b/crates/radicle/Cargo.toml
index 1259d7372c..228a18a735 100644
--- a/crates/radicle/Cargo.toml
+++ b/crates/radicle/Cargo.toml
@@ -13,6 +13,8 @@ bstr = ["radicle-git-ref-format/bstr"]
default = []
gix = ["radicle-oid/gix"]
i2p = ["cyphernet/i2p"]
+## Enable HTTP fetch from plc.directory for the PLC DID cache.
+plc = ["dep:ureq"]
test = ["tempfile", "qcheck", "radicle-crypto/test", "radicle-cob/test"]
qcheck = [
"radicle-core/qcheck",
@@ -30,6 +32,7 @@ tor = ["cyphernet/tor"]
[dependencies]
base64 = "0.21.3"
+ureq = { version = "2.9", optional = true, default-features = false, features = ["tls"] }
bytesize = { version = "2", features = ["serde"] }
cyphernet = { workspace = true, features = ["dns", "p2p-ed25519"] }
dunce = { workspace = true }
diff --git a/crates/radicle/src/cob/common.rs b/crates/radicle/src/cob/common.rs
index db9bee1bd7..34e5de3686 100644
--- a/crates/radicle/src/cob/common.rs
+++ b/crates/radicle/src/cob/common.rs
@@ -143,7 +143,8 @@ impl Author {
&self.id
}
- pub fn public_key(&self) -> &PublicKey {
+ /// Device key when this author is a `did:key`.
+ pub fn public_key(&self) -> Option<&PublicKey> {
self.id.as_key()
}
}
@@ -160,6 +161,12 @@ impl From<PublicKey> for Author {
}
}
+impl From<Did> for Author {
+ fn from(value: Did) -> Self {
+ Self::new(value)
+ }
+}
+
#[derive(thiserror::Error, Debug)]
pub enum ReactionError {
#[error("invalid reaction")]
diff --git a/crates/radicle/src/cob/identity.rs b/crates/radicle/src/cob/identity.rs
index 691064cff6..debc01cf43 100644
--- a/crates/radicle/src/cob/identity.rs
+++ b/crates/radicle/src/cob/identity.rs
@@ -430,22 +430,26 @@ impl store::Cob for Identity {
assert_eq!(root.commit, op.id);
let founder = root.delegates().first();
- if founder.as_key() != &op.author {
+ if founder != &op.author {
return Err(ApplyError::Init("delegate does not match committer"));
}
// Verify signature against root document. Since there is no previous document,
// we verify it against itself.
+ let founder_key = founder
+ .as_key()
+ .ok_or(ApplyError::Init("root delegate must be did:key"))?;
if root
- .verify_signature(founder, &signature, root.blob)
+ .verify_signature(founder_key, &signature, root.blob)
.is_err()
{
- return Err(ApplyError::InvalidSignature(**founder, root.blob));
+ return Err(ApplyError::InvalidSignature(*founder_key, root.blob));
}
let revision = Revision::new(
root.commit,
title,
description,
op.author.into(),
+ op.signing_key,
root.blob,
root.doc,
State::Accepted,
@@ -466,7 +470,7 @@ impl store::Cob for Identity {
let concurrent = concurrent.into_iter().collect::<Vec<_>>();
for action in op.actions {
- match self.action(action, id, op.author, op.timestamp, repo) {
+ match self.action(action, id, op.author, op.signing_key, op.timestamp, repo) {
Ok(()) => {}
// This particular error is returned when there is a mismatch between the expected
// and the actual state of a revision, which can happen concurrently. Therefore
@@ -497,10 +501,11 @@ impl Identity {
action: Action,
id: EntryId,
author: ActorId,
+ signing_key: PublicKey,
timestamp: Timestamp,
repo: &R,
) -> Result<(), ApplyError> {
- let did = author.into();
+ let did = author;
match action {
action @ (Action::RevisionAccept { revision: id, .. }
@@ -526,7 +531,7 @@ impl Identity {
.revision(&parent_id)
.ok_or(ApplyError::Missing(parent_id))?;
- if !parent.is_delegate(&did) {
+ if !parent.is_delegate(&did) && !parent.doc.is_delegate_key(&signing_key) {
return Err(ApplyError::non_delegate_unauthorized(did, &action));
}
@@ -550,15 +555,15 @@ impl Identity {
}
parent
- .verify_signature(&author, &signature, revision.blob)
+ .verify_signature(&signing_key, &signature, revision.blob)
.map_err(|_source| {
- ApplyError::InvalidSignature(author, revision.blob)
+ ApplyError::InvalidSignature(signing_key, revision.blob)
})?;
if self
.revision_mut(&id)?
.verdicts
- .insert(author, Verdict::Accept(signature))
+ .insert(signing_key, Verdict::Accept(signature))
.is_some()
{
return Err(ApplyError::DuplicateVerdict);
@@ -571,7 +576,11 @@ impl Identity {
parent.delegates().len() - parent.majority();
let revision = self.revision_mut(&id)?;
- if revision.verdicts.insert(author, Verdict::Reject).is_some() {
+ if revision
+ .verdicts
+ .insert(signing_key, Verdict::Reject)
+ .is_some()
+ {
return Err(ApplyError::DuplicateVerdict);
}
@@ -595,11 +604,11 @@ impl Identity {
log::debug!("Cannot edit revision {id} because it is not active.",);
return Err(ApplyError::UnexpectedState);
}
- if revision.author.public_key() != &author {
+ if revision.author.id() != &author {
log::debug!(
"{} cannot edit revision created by {}.",
author,
- revision.author.public_key()
+ revision.author.id()
);
// Since the author never changes, we can safely mark this as invalid.
return Err(ApplyError::NotAuthorized);
@@ -611,10 +620,10 @@ impl Identity {
Action::RevisionRedact { revision: id } => {
let revision = self.revision_mut(&id)?;
- if revision.author.public_key() != &author {
+ if revision.author.id() != &author {
log::debug!(
"{author} cannot redact revision created by {}.",
- revision.author.public_key()
+ revision.author.id()
);
// Since the author never changes, we can safely mark this as invalid.
return Err(ApplyError::NotAuthorized);
@@ -645,9 +654,9 @@ impl Identity {
let parent_id = parent_id.ok_or(ApplyError::MissingParent)?;
let parent = self.revision(&parent_id).ok_or(ApplyError::MissingParent)?;
- if !parent.is_delegate(&did) {
+ if !parent.is_delegate(&did) && !parent.doc.is_delegate_key(&signing_key) {
return Err(ApplyError::NonDelegateUnauthorized {
- author: author.into(),
+ author,
action: "create a revision".to_string(),
});
}
@@ -658,8 +667,11 @@ impl Identity {
}
// Verify signature over new blob, using trusted delegates.
- if parent.verify_signature(&author, &signature, blob).is_err() {
- return Err(ApplyError::InvalidSignature(author, blob));
+ if parent
+ .verify_signature(&signing_key, &signature, blob)
+ .is_err()
+ {
+ return Err(ApplyError::InvalidSignature(signing_key, blob));
}
// If the parent is already rejected or redacted, this revision is dead on arrival.
@@ -708,6 +720,7 @@ impl Identity {
title,
description,
author.into(),
+ signing_key,
blob,
doc,
state,
@@ -724,7 +737,7 @@ impl Identity {
"Stripping implicit accept from revision {id} by {did}: \
already accepted the active revision {revision_id}.",
);
- self.revision_mut(&id)?.verdicts.remove(&author);
+ self.revision_mut(&id)?.verdicts.remove(&signing_key);
}
if state == State::Active {
@@ -1078,6 +1091,7 @@ impl Revision {
title: cob::Title,
description: String,
author: Author,
+ signing_key: PublicKey,
blob: Oid,
doc: Doc,
state: State,
@@ -1085,7 +1099,7 @@ impl Revision {
parent: Option<RevisionId>,
timestamp: Timestamp,
) -> Self {
- let verdicts = HashMap::from_iter([(*author.public_key(), Verdict::Accept(signature))]);
+ let verdicts = HashMap::from_iter([(signing_key, Verdict::Accept(signature))]);
Self {
id,
diff --git a/crates/radicle/src/cob/issue.rs b/crates/radicle/src/cob/issue.rs
index 0fd572781c..e9ee1a363a 100644
--- a/crates/radicle/src/cob/issue.rs
+++ b/crates/radicle/src/cob/issue.rs
@@ -290,7 +290,7 @@ impl Issue {
self.thread
.comments()
.next()
- .map(|(_, c)| Author::new(c.author()))
+ .map(|(_, c)| Author::new(*c.author()))
.expect("Issue::author: at least one comment is present")
}
@@ -338,11 +338,11 @@ impl Issue {
actor: &ActorId,
doc: &Doc,
) -> Result<Authorization, Error> {
- if doc.is_delegate(&actor.into()) {
+ if doc.is_delegate(actor) || actor.as_key().is_some_and(|k| doc.is_delegate_key(k)) {
// A delegate is authorized to do all actions.
return Ok(Authorization::Allow);
}
- let author: ActorId = *self.author().id().as_key();
+ let author = *self.author().id();
let outcome = match action {
// Only delegate can assign someone to an issue.
Action::Assign { assignees } => {
@@ -1255,7 +1255,7 @@ mod test {
let reactions = issue.comment(&comment).unwrap().reactions();
let authors = reactions.get(&reaction).unwrap();
- assert_eq!(*authors.first().unwrap(), node.signer.public_key());
+ assert_eq!(**authors.first().unwrap(), Did::from(*node.signer.public_key()));
// TODO: Test multiple reactions from same author and different authors
}
@@ -1340,7 +1340,7 @@ mod test {
#[test]
fn test_issue_comment() {
let test::setup::NodeWithRepo { node, repo, .. } = test::setup::NodeWithRepo::default();
- let author = node.signer.public_key();
+ let author = Did::from(*node.signer.public_key());
let mut issues = Cache::no_cache(&*repo, &node.signer).unwrap();
let mut issue = issues
.create(
@@ -1366,11 +1366,11 @@ mod test {
let (_, c2) = &issue.comments().nth(2).unwrap();
assert_eq!(c0.body(), "Blah blah blah.");
- assert_eq!(c0.author(), author);
+ assert_eq!(*c0.author(), author);
assert_eq!(c1.body(), "Ho ho ho.");
- assert_eq!(c1.author(), author);
+ assert_eq!(*c1.author(), author);
assert_eq!(c2.body(), "Ha ha ha.");
- assert_eq!(c2.author(), author);
+ assert_eq!(*c2.author(), author);
}
#[test]
diff --git a/crates/radicle/src/cob/op.rs b/crates/radicle/src/cob/op.rs
index 0bf976c583..234a5ea4aa 100644
--- a/crates/radicle/src/cob/op.rs
+++ b/crates/radicle/src/cob/op.rs
@@ -4,17 +4,23 @@ use serde::Serialize;
use thiserror::Error;
use radicle_cob::history::{Entry, EntryId};
-use radicle_crypto::PublicKey;
use crate::cob;
use crate::cob::Timestamp;
+use crate::crypto;
use crate::git;
use crate::identity;
use crate::identity::DocAt;
+use crate::identity::Did;
+use crate::identity::plc::{DidResolver, KeyOnlyResolver};
use crate::storage::ReadRepository;
/// The author of an [`Op`].
-pub type ActorId = PublicKey;
+///
+/// Logical identity: a device [`Did::Key`] or an ATProto [`Did::Plc`]. COB
+/// signatures still embed the device Ed25519 key; authorship is attributed via
+/// a [`DidResolver`] when loading entries.
+pub type ActorId = Did;
/// Error decoding an operation from an entry.
#[derive(Error, Debug)]
@@ -58,8 +64,10 @@ pub struct Op<A> {
pub id: EntryId,
/// The action carried out by this operation.
pub actions: NonEmpty<A>,
- /// The author of the operation.
+ /// The logical author of the operation (`did:key` or `did:plc`).
pub author: ActorId,
+ /// Device Ed25519 key that signed the underlying change entry.
+ pub signing_key: crypto::PublicKey,
/// Timestamp of this operation.
pub timestamp: Timestamp,
/// Parent operations.
@@ -89,6 +97,7 @@ impl<A> Op<A> {
id: EntryId,
actions: impl Into<NonEmpty<A>>,
author: ActorId,
+ signing_key: crypto::PublicKey,
timestamp: impl Into<Timestamp>,
identity: Option<git::Oid>,
manifest: Manifest,
@@ -97,6 +106,7 @@ impl<A> Op<A> {
id,
actions: actions.into(),
author,
+ signing_key,
timestamp: timestamp.into(),
parents: vec![],
related: vec![],
@@ -158,12 +168,21 @@ impl<A> Op<A> {
impl From<Entry> for Op<Vec<u8>> {
fn from(entry: Entry) -> Self {
+ Self::from_entry(&entry, &KeyOnlyResolver)
+ }
+}
+
+impl Op<Vec<u8>> {
+ /// Build an op from a change entry, attributing authorship via `resolver`.
+ pub fn from_entry(entry: &Entry, resolver: &impl DidResolver) -> Self {
+ let signing_key = *entry.author();
Self {
id: *entry.id(),
actions: entry.contents().clone(),
- author: *entry.author(),
- parents: entry.parents,
- related: entry.related,
+ author: resolver.actor_for_key(&signing_key),
+ signing_key,
+ parents: entry.parents.clone(),
+ related: entry.related.clone(),
timestamp: Timestamp::from_secs(entry.timestamp),
identity: entry.resource,
manifest: entry.manifest.clone(),
@@ -178,6 +197,19 @@ where
type Error = OpEncodingError;
fn try_from(entry: &'a Entry) -> Result<Self, Self::Error> {
+ Self::try_from_entry(entry, &KeyOnlyResolver)
+ }
+}
+
+impl<A> Op<A>
+where
+ for<'de> A: serde::Deserialize<'de>,
+{
+ /// Load and decode an op, attributing authorship via `resolver`.
+ pub fn try_from_entry(
+ entry: &Entry,
+ resolver: &impl DidResolver,
+ ) -> Result<Self, OpEncodingError> {
let id = *entry.id();
let identity = entry.resource().copied();
let actions: Vec<_> = entry
@@ -190,18 +222,18 @@ where
// SAFETY: Entry is guaranteed to have at least one operation.
#[allow(clippy::unwrap_used)]
let actions = NonEmpty::from_vec(actions).unwrap();
- let op = Op {
+ let signing_key = *entry.author();
+ Ok(Op {
id,
actions,
- author: *entry.author(),
+ author: resolver.actor_for_key(&signing_key),
+ signing_key,
timestamp: Timestamp::from_secs(entry.timestamp),
parents: entry.parents.to_owned(),
related: entry.related.to_owned(),
identity,
manifest,
- };
-
- Ok(op)
+ })
}
}
diff --git a/crates/radicle/src/cob/patch.rs b/crates/radicle/src/cob/patch.rs
index 451bd33490..02099abddb 100644
--- a/crates/radicle/src/cob/patch.rs
+++ b/crates/radicle/src/cob/patch.rs
@@ -26,7 +26,6 @@ use crate::cob::thread;
use crate::cob::thread::Thread;
use crate::cob::thread::{Comment, CommentId, Edit, Reactions};
use crate::cob::{ActorId, Embed, EntryId, ObjectId, TypeName, Uri, op, store};
-use crate::crypto::PublicKey;
use crate::git;
use crate::identity::PayloadError;
use crate::identity::doc::{DefaultBranchError, DocAt, DocError};
@@ -701,7 +700,7 @@ impl Patch {
/// List of patch revisions by the patch author. The initial changeset is part of the
/// first revision.
pub fn updates(&self) -> impl DoubleEndedIterator<Item = (RevisionId, &Revision)> {
- self.revisions_by(self.author().public_key())
+ self.revisions_by(self.author().id())
}
/// List of all patch revisions by all authors.
@@ -717,10 +716,10 @@ impl Patch {
/// List of patch revisions by the given author.
pub fn revisions_by<'a>(
&'a self,
- author: &'a PublicKey,
+ author: &'a Did,
) -> impl DoubleEndedIterator<Item = (RevisionId, &'a Revision)> {
self.revisions()
- .filter(move |(_, r)| r.author.public_key() == author)
+ .filter(move |(_, r)| r.author.id() == author)
}
/// List of patch reviews of the given revision.
@@ -740,7 +739,7 @@ impl Patch {
/// List of patch assignees.
pub fn assignees(&self) -> impl Iterator<Item = Did> + '_ {
- self.assignees.iter().map(Did::from)
+ self.assignees.iter().copied()
}
/// Get the merges.
@@ -791,12 +790,12 @@ impl Patch {
/// Latest revision by the patch author.
pub fn latest(&self) -> (RevisionId, &Revision) {
- self.latest_by(self.author().public_key())
+ self.latest_by(self.author().id())
.expect("Patch::latest: there is always at least one revision")
}
/// Latest revision by the given author.
- pub fn latest_by<'a>(&'a self, author: &'a PublicKey) -> Option<(RevisionId, &'a Revision)> {
+ pub fn latest_by<'a>(&'a self, author: &'a Did) -> Option<(RevisionId, &'a Revision)> {
self.revisions_by(author).next_back()
}
@@ -832,18 +831,18 @@ impl Patch {
actor: &ActorId,
doc: &Doc,
) -> Result<Authorization, Error> {
- if doc.is_delegate(&actor.into()) {
+ if doc.is_delegate(actor) || actor.as_key().is_some_and(|k| doc.is_delegate_key(k)) {
// A delegate is authorized to do all actions.
return Ok(Authorization::Allow);
}
- let author = self.author().id().as_key();
+ let author = *self.author().id();
let outcome = match action {
// The patch author can edit the patch and change its state.
- Action::Edit { .. } => Authorization::from(actor == author),
+ Action::Edit { .. } => Authorization::from(*actor == author),
Action::Lifecycle { state } => Authorization::from(match state {
- Lifecycle::Open => actor == author,
- Lifecycle::Draft => actor == author,
- Lifecycle::Archived => actor == author,
+ Lifecycle::Open => *actor == author,
+ Lifecycle::Draft => *actor == author,
+ Lifecycle::Archived => *actor == author,
}),
// Only delegates can carry out these actions.
Action::Label { labels } => {
@@ -861,7 +860,7 @@ impl Patch {
if let Ok(crefs) = doc.canonical_refs()
&& let Some((_, rule)) = crefs.rules().matches(&expected_dest).next()
{
- return Ok(Authorization::from(rule.allowed().contains(&actor.into())));
+ return Ok(Authorization::from(rule.allowed().contains(actor)));
}
Authorization::Deny
@@ -870,7 +869,7 @@ impl Patch {
Action::Review { .. } => Authorization::Allow,
Action::ReviewRedact { review, .. } => {
if let Some((_, review)) = lookup::review(self, review)? {
- Authorization::from(actor == review.author.public_key())
+ Authorization::from(actor == review.author.id())
} else {
// Redacted.
Authorization::Unknown
@@ -878,7 +877,7 @@ impl Patch {
}
Action::ReviewEdit(edit) => {
if let Some((_, review)) = lookup::review(self, edit.review_id())? {
- Authorization::from(actor == review.author.public_key())
+ Authorization::from(actor == review.author.id())
} else {
// Redacted.
Authorization::Unknown
@@ -909,8 +908,8 @@ impl Patch {
{
return Ok(Authorization::from(
actor == comment.author()
- || actor == review.author.public_key()
- || actor == revision.author.public_key(),
+ || actor == review.author.id()
+ || actor == revision.author.id(),
));
}
// Redacted.
@@ -922,7 +921,7 @@ impl Patch {
// Only the revision author can edit or redact their revision.
Action::RevisionEdit { revision, .. } | Action::RevisionRedact { revision, .. } => {
if let Some(revision) = lookup::revision(self, revision)? {
- Authorization::from(actor == revision.author.public_key())
+ Authorization::from(actor == revision.author.id())
} else {
// Redacted.
Authorization::Unknown
@@ -1258,7 +1257,10 @@ impl Patch {
// of the merge author. We simply skip it, which allows archiving in
// case of a rebase off the master branch, or a redaction of the
// merge.
- let Ok(head) = repo.reference_oid(&author, &expected_branch) else {
+ let Some(nid) = author.as_key() else {
+ return Ok(());
+ };
+ let Ok(head) = repo.reference_oid(nid, &expected_branch) else {
return Ok(());
};
if commit != head && !repo.is_ancestor_of(commit, head)? {
@@ -1611,7 +1613,7 @@ impl Revision {
timestamp: Timestamp,
resolves: BTreeSet<(EntryId, CommentId)>,
) -> Self {
- let description = Edit::new(*author.public_key(), description, timestamp, Vec::default());
+ let description = Edit::new(*author.id(), description, timestamp, Vec::default());
Self {
id,
@@ -1643,7 +1645,7 @@ impl Revision {
&self.description.last().embeds
}
- pub fn reactions(&self) -> &BTreeMap<Option<CodeLocation>, BTreeSet<(PublicKey, Reaction)>> {
+ pub fn reactions(&self) -> &BTreeMap<Option<CodeLocation>, BTreeSet<(ActorId, Reaction)>> {
&self.reactions
}
@@ -1688,7 +1690,7 @@ impl Revision {
}
/// Reviews of this revision's changes (one per actor).
- pub fn reviews(&self) -> impl DoubleEndedIterator<Item = (&PublicKey, &Review)> {
+ pub fn reviews(&self) -> impl DoubleEndedIterator<Item = (&ActorId, &Review)> {
self.reviews.iter()
}
@@ -1851,7 +1853,7 @@ impl Review {
embeds: Vec<Embed<Uri>>,
timestamp: Timestamp,
) -> Self {
- let summary = NonEmpty::new(Edit::new(*author.public_key(), summary, timestamp, embeds));
+ let summary = NonEmpty::new(Edit::new(*author.id(), summary, timestamp, embeds));
Self {
id,
author,
@@ -3055,7 +3057,7 @@ mod test {
resolves,
);
let comment = Comment::new(
- *author,
+ author,
"#1 comment".to_string(),
None,
None,
@@ -3522,7 +3524,7 @@ mod test {
assert_eq!(merges.len(), 1);
let (merger, merge) = merges.first().unwrap();
- assert_eq!(*merger, alice.signer.public_key());
+ assert_eq!(*merger, &Did::from(*alice.signer.public_key()));
assert_eq!(merge.commit, branch.base);
}
@@ -3558,7 +3560,7 @@ mod test {
let (_, revision) = patch.latest();
assert_eq!(revision.reviews.len(), 1);
- let review = revision.review_by(alice.signer.public_key()).unwrap();
+ let review = revision.review_by(&Did::from(*alice.signer.public_key())).unwrap();
assert_eq!(review.verdict(), Some(Verdict::Accept));
assert_eq!(review.summary(), "LGTM");
@@ -3795,7 +3797,7 @@ mod test {
.unwrap(); // Overwrite the comment.
let (_, revision) = patch.latest();
- let review = revision.review_by(alice.signer.public_key()).unwrap();
+ let review = revision.review_by(&Did::from(*alice.signer.public_key())).unwrap();
assert_eq!(review.verdict(), Some(Verdict::Reject));
assert_eq!(review.summary(), "Whoops!");
}
@@ -3826,7 +3828,7 @@ mod test {
.unwrap(); // This review is ignored, since there is already a review by this author.
let (_, revision) = patch.latest();
- let review = revision.review_by(alice.signer.public_key()).unwrap();
+ let review = revision.review_by(&Did::from(*alice.signer.public_key())).unwrap();
assert_eq!(review.verdict(), Some(Verdict::Accept));
}
@@ -3869,7 +3871,7 @@ mod test {
.unwrap();
let (_, revision) = patch.latest();
- let review = revision.review_by(alice.signer.public_key()).unwrap();
+ let review = revision.review_by(&Did::from(*alice.signer.public_key())).unwrap();
assert_eq!(review.verdict(), Some(Verdict::Reject));
assert_eq!(review.comments().count(), 2);
assert_eq!(review.comments().next().unwrap().1.body(), "First comment!");
@@ -3917,7 +3919,7 @@ mod test {
.unwrap();
let (_, revision) = patch.latest();
- let review = revision.review_by(alice.signer.public_key()).unwrap();
+ let review = revision.review_by(&Did::from(*alice.signer.public_key())).unwrap();
let (_, comment) = review.comments().next().unwrap();
assert_eq!(comment.body(), "I like these lines of code");
@@ -3958,7 +3960,7 @@ mod test {
let id = patch.id;
let patch = patches.get_mut(&id).unwrap();
let (_, revision) = patch.latest();
- let review = revision.review_by(alice.signer.public_key()).unwrap();
+ let review = revision.review_by(&Did::from(*alice.signer.public_key())).unwrap();
assert_eq!(review.verdict(), Some(Verdict::Accept));
assert_eq!(review.summary(), "");
diff --git a/crates/radicle/src/cob/patch/cache.rs b/crates/radicle/src/cob/patch/cache.rs
index 3b05415674..c9419d1652 100644
--- a/crates/radicle/src/cob/patch/cache.rs
+++ b/crates/radicle/src/cob/patch/cache.rs
@@ -753,7 +753,7 @@ mod tests {
resolves,
);
let comment = Comment::new(
- *author,
+ author,
"#1 comment".to_string(),
None,
None,
diff --git a/crates/radicle/src/cob/patch/encoding/review.rs b/crates/radicle/src/cob/patch/encoding/review.rs
index ae626effad..049a946d68 100644
--- a/crates/radicle/src/cob/patch/encoding/review.rs
+++ b/crates/radicle/src/cob/patch/encoding/review.rs
@@ -84,7 +84,7 @@ impl Summary {
fn into_edits(self, author: &Author, timestamp: &Timestamp) -> NonEmpty<Edit> {
match self {
Summary::V1(summary) => NonEmpty::new(Edit::new(
- *author.public_key(),
+ *author.id(),
summary.unwrap_or_default(),
*timestamp,
vec![],
diff --git a/crates/radicle/src/cob/test.rs b/crates/radicle/src/cob/test.rs
index 90182ea596..c4921f7bcf 100644
--- a/crates/radicle/src/cob/test.rs
+++ b/crates/radicle/src/cob/test.rs
@@ -161,7 +161,8 @@ pub trait SignerOpExt: crypto::Signer {
let oid =
crate::git::raw::Oid::hash_object(crate::git::raw::ObjectType::Blob, &data).unwrap();
let id = oid.into();
- let author = self.did().into();
+ let author = self.did();
+ let signing_key = *self.verifying_key().public_key();
let actions = NonEmpty::from_vec(actions).unwrap();
let manifest = Manifest::new(T::type_name().clone(), Version::default());
let parents = vec![];
@@ -171,6 +172,7 @@ pub trait SignerOpExt: crypto::Signer {
id,
actions,
author,
+ signing_key,
parents,
related,
timestamp,
diff --git a/crates/radicle/src/git/canonical.rs b/crates/radicle/src/git/canonical.rs
index ea3f3db514..039b8b5c5f 100644
--- a/crates/radicle/src/git/canonical.rs
+++ b/crates/radicle/src/git/canonical.rs
@@ -497,7 +497,15 @@ impl Missing {
fn found<'a>(&mut self, did: &Did, refname: &Qualified<'a>) {
self.objects.remove(did);
self.refs
- .remove(&refname.with_namespace((did.as_key()).into()).to_owned());
+ .remove(
+ &refname
+ .with_namespace(
+ (did.as_key()
+ .expect("canonical rules only reference did:key namespaces"))
+ .into(),
+ )
+ .to_owned(),
+ );
}
}
diff --git a/crates/radicle/src/git/canonical/effects.rs b/crates/radicle/src/git/canonical/effects.rs
index b63a8118ed..7e05266e26 100644
--- a/crates/radicle/src/git/canonical/effects.rs
+++ b/crates/radicle/src/git/canonical/effects.rs
@@ -214,7 +214,11 @@ impl FindObjects for git::raw::Repository {
let mut missing_refs = BTreeSet::new();
let mut missing_objects = BTreeMap::new();
for did in dids {
- let name = &refname.with_namespace(did.as_key().into());
+ let name = &refname.with_namespace(
+ did.as_key()
+ .expect("canonical rules only reference did:key namespaces")
+ .into(),
+ );
let reference = match self.find_reference(name.as_str()) {
Ok(reference) => reference,
Err(e) if e.is_not_found() => {
diff --git a/crates/radicle/src/identity.rs b/crates/radicle/src/identity.rs
index f9a4b70b56..84cf8e1a59 100644
--- a/crates/radicle/src/identity.rs
+++ b/crates/radicle/src/identity.rs
@@ -1,13 +1,35 @@
#![deny(clippy::unwrap_used)]
+//! Repository and actor identity.
+//!
+//! # Device vs person DIDs
+//!
+//! Radicle distinguishes **device** identities from **logical** (person/account)
+//! identities:
+//!
+//! - [`Did::Key`] (`did:key:…`) is an Ed25519 device key. It is also the
+//! [`crate::prelude::NodeId`] used for Noise, git namespaces, and remotes.
+//! - [`Did::Plc`] (`did:plc:…`) is an ATProto PLC account DID. It may act as a
+//! COB author, repository delegate, or follow target. Verifying keys are
+//! resolved with [`plc::DidResolver`]: pins in the `xyz.radicle.did` identity
+//! payload for authz-critical uses, and a local PLC cache for authorship.
+//!
+//! Signing remains Ed25519 only. A `did:plc` is usable when its DID document
+//! exposes an Ed25519 Multikey (`#atproto` preferred, else `#radicle`).
+
pub mod crefs;
pub mod did;
pub mod doc;
+pub mod plc;
pub mod project;
pub use crefs::CanonicalRefs;
pub use crypto::PublicKey;
pub use did::Did;
pub use doc::{Doc, DocAt, DocError, IdError, PayloadError, RawDoc, RepoId, Visibility};
+pub use plc::{
+ DidDocument, DidPayload, DidResolver, HybridResolver, KeyOnlyResolver, PinnedVerification,
+ PlcCache, PlcId, ResolveError, ResolvedDid, key_acts_for,
+};
pub use project::Project;
pub use crate::cob::identity::{Action, Error, Identity, IdentityMut, TYPENAME};
diff --git a/crates/radicle/src/identity/did.rs b/crates/radicle/src/identity/did.rs
index fb61c98888..abff310c86 100644
--- a/crates/radicle/src/identity/did.rs
+++ b/crates/radicle/src/identity/did.rs
@@ -1,10 +1,11 @@
-use std::ops::Deref;
-use std::{fmt, str::FromStr};
+use std::fmt;
+use std::str::FromStr;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::crypto;
+use crate::identity::plc::{PlcId, PlcIdError};
#[derive(Error, Debug)]
pub enum DidError {
@@ -12,51 +13,110 @@ pub enum DidError {
Did(String),
#[error("invalid public key: {0}")]
PublicKey(#[from] crypto::PublicKeyError),
+ #[error("invalid did:plc: {0}")]
+ Plc(#[from] PlcIdError),
}
+/// A decentralized identifier used as a logical Radicle identity.
+///
+/// Device identities use the DID `key` method (`did:key:…`). Person / account
+/// identities may use ATProto PLC (`did:plc:…`); verifying keys for PLC DIDs
+/// are obtained via [`crate::identity::plc::DidResolver`].
+///
+/// Transport addresses ([`crate::prelude::NodeId`], Noise, git namespaces) remain
+/// Ed25519 device keys and are never `did:plc`.
#[derive(Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
#[serde(into = "String", try_from = "String")]
-pub struct Did(crypto::PublicKey);
+pub enum Did {
+ /// Ed25519 device DID (`did:key:…`).
+ Key(crypto::PublicKey),
+ /// ATProto PLC DID (`did:plc:…`).
+ Plc(PlcId),
+}
impl Did {
- /// We use the format specified by the DID `key` method, which is described as:
- ///
- /// `did:key:MULTIBASE(base58-btc, MULTICODEC(public-key-type, raw-public-key-bytes))`
- ///
+ /// Encode as a DID URI string.
pub fn encode(&self) -> String {
- format!("did:key:{}", self.0.to_human())
+ match self {
+ Self::Key(key) => format!("did:key:{}", key.to_human()),
+ Self::Plc(id) => id.encode(),
+ }
}
+ /// Decode a DID URI (`did:key:…` or `did:plc:…`).
+ ///
+ /// For backward compatibility with older COB payloads that stored authors as
+ /// bare multibase Ed25519 keys, a bare `z6Mk…` string is accepted as
+ /// [`Did::Key`].
pub fn decode(input: &str) -> Result<Self, DidError> {
- let key = input
- .strip_prefix("did:key:")
- .ok_or_else(|| DidError::Did(input.to_owned()))?;
+ if let Some(key) = input.strip_prefix("did:key:") {
+ return crypto::PublicKey::from_str(key)
+ .map(Self::Key)
+ .map_err(DidError::from);
+ }
+ if input.starts_with("did:plc:") {
+ return PlcId::decode(input)
+ .map(Self::Plc)
+ .map_err(DidError::from);
+ }
+ if let Ok(key) = crypto::PublicKey::from_str(input) {
+ return Ok(Self::Key(key));
+ }
+ Err(DidError::Did(input.to_owned()))
+ }
+
+ /// Return the embedded Ed25519 key when this is a `did:key`.
+ pub fn as_key(&self) -> Option<&crypto::PublicKey> {
+ match self {
+ Self::Key(key) => Some(key),
+ Self::Plc(_) => None,
+ }
+ }
- crypto::PublicKey::from_str(key)
- .map(Did)
- .map_err(DidError::from)
+ /// Return the PLC identifier when this is a `did:plc`.
+ pub fn as_plc(&self) -> Option<&PlcId> {
+ match self {
+ Self::Plc(id) => Some(id),
+ Self::Key(_) => None,
+ }
}
- pub fn as_key(&self) -> &crypto::PublicKey {
- self.deref()
+ /// Whether this DID is a device `did:key`.
+ pub fn is_key(&self) -> bool {
+ matches!(self, Self::Key(_))
+ }
+
+ /// Whether this DID is a `did:plc`.
+ pub fn is_plc(&self) -> bool {
+ matches!(self, Self::Plc(_))
}
}
impl From<&crypto::PublicKey> for Did {
fn from(key: &crypto::PublicKey) -> Self {
- Self(*key)
+ Self::Key(*key)
}
}
impl From<crypto::PublicKey> for Did {
fn from(key: crypto::PublicKey) -> Self {
- (&key).into()
+ Self::Key(key)
}
}
-impl From<Did> for crypto::PublicKey {
- fn from(Did(key): Did) -> Self {
- key
+impl From<PlcId> for Did {
+ fn from(id: PlcId) -> Self {
+ Self::Plc(id)
+ }
+}
+
+impl TryFrom<Did> for crypto::PublicKey {
+ type Error = DidError;
+
+ fn try_from(did: Did) -> Result<Self, Self::Error> {
+ did.as_key()
+ .copied()
+ .ok_or_else(|| DidError::Did(did.encode()))
}
}
@@ -90,15 +150,7 @@ impl fmt::Display for Did {
impl fmt::Debug for Did {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
- write!(f, "Did({:?})", self.to_string())
- }
-}
-
-impl Deref for Did {
- type Target = crypto::PublicKey;
-
- fn deref(&self) -> &Self::Target {
- &self.0
+ write!(f, "Did({})", self.encode())
}
}
@@ -110,9 +162,9 @@ mod test {
#[test]
fn test_did_encode_decode() {
let input = "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK";
- let Did(key) = Did::decode(input).unwrap();
-
- assert_eq!(Did::from(key).encode(), input);
+ let did = Did::decode(input).unwrap();
+ assert!(matches!(did, Did::Key(_)));
+ assert_eq!(did.encode(), input);
}
#[test]
@@ -121,4 +173,26 @@ mod test {
Did::decode("did:key:z6MkjchhfUsD6mmvni8mCdXHw216Xrm9bQe2mBH1P5RDjVJG").unwrap();
Did::decode("did:key:z6MknGc3ocHs3zdPiJbnaaqDi58NGb4pk1Sp9WxWufuXSdxf").unwrap();
}
+
+ #[test]
+ fn test_did_plc_encode_decode() {
+ let input = "did:plc:ewvi7nxzyoun6grtyetllrat";
+ let did = Did::decode(input).unwrap();
+ assert!(matches!(did, Did::Plc(_)));
+ assert_eq!(did.encode(), input);
+ assert!(did.as_key().is_none());
+ }
+
+ #[test]
+ fn test_did_accepts_bare_multibase_key() {
+ let input = "z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK";
+ let did = Did::decode(input).unwrap();
+ assert!(matches!(did, Did::Key(_)));
+ assert_eq!(did.encode(), format!("did:key:{input}"));
+ }
+
+ #[test]
+ fn test_did_rejects_unknown_method() {
+ assert!(Did::decode("did:web:example.com").is_err());
+ }
}
diff --git a/crates/radicle/src/identity/doc.rs b/crates/radicle/src/identity/doc.rs
index a6147124bf..1b5e86575a 100644
--- a/crates/radicle/src/identity/doc.rs
+++ b/crates/radicle/src/identity/doc.rs
@@ -25,6 +25,7 @@ use crate::git::fmt::Qualified;
use crate::git::fmt::RefString;
use crate::git::raw::ErrorExt as _;
use crate::identity::crefs;
+use crate::identity::plc::{DidPayload, PinnedVerification};
use crate::identity::{Did, project::Project};
use crate::storage;
use crate::storage::{ReadRepository, RepositoryError};
@@ -244,6 +245,15 @@ impl PayloadId {
.expect("PayloadId::canonical_refs: type name is valid"),
)
}
+
+ /// Pinned DID verification material (`did:plc` keys for offline authz).
+ pub fn did() -> Self {
+ Self(
+ // SAFETY: We know this is valid.
+ TypeName::from_str("xyz.radicle.did")
+ .expect("PayloadId::did: type name is valid"),
+ )
+ }
}
#[derive(Debug, Error)]
@@ -276,6 +286,29 @@ impl Payload {
}
}
+impl RawDoc {
+ /// Set or replace the `xyz.radicle.did` pin payload.
+ pub fn set_did_payload(&mut self, pins: DidPayload) -> Result<(), PayloadError> {
+ let value = serde_json::to_value(pins)?;
+ self.payload.insert(PayloadId::did(), Payload::from(value));
+ Ok(())
+ }
+
+ /// Pin verifying keys for `did` in the `xyz.radicle.did` payload.
+ pub fn pin_did(
+ &mut self,
+ did: Did,
+ keys: impl IntoIterator<Item = PublicKey>,
+ ) -> Result<(), PayloadError> {
+ let mut pins = match self.payload.get(&PayloadId::did()) {
+ Some(payload) => serde_json::from_value((**payload).clone())?,
+ None => DidPayload::default(),
+ };
+ pins.insert(did, PinnedVerification::from_keys(keys));
+ self.set_did_payload(pins)
+ }
+}
+
impl From<serde_json::Value> for Payload {
fn from(value: serde_json::Value) -> Self {
Self { value }
@@ -941,6 +974,62 @@ impl Doc {
self.delegates.contains(did)
}
+ /// Whether the device `key` may act as a repository delegate.
+ ///
+ /// For `did:key` delegates this is equality. For `did:plc` delegates the key
+ /// must appear in the pinned verifying keys under `xyz.radicle.did`.
+ pub fn is_delegate_key(&self, key: &PublicKey) -> bool {
+ let key_did = Did::from(key);
+ if self.is_delegate(&key_did) {
+ return true;
+ }
+ let Ok(Some(pins)) = self.did_payload() else {
+ return false;
+ };
+ self.delegates.iter().any(|did| {
+ did.is_plc()
+ && pins
+ .keys_for(did)
+ .ok()
+ .flatten()
+ .is_some_and(|keys| keys.iter().any(|k| k == key))
+ })
+ }
+
+ /// Load the optional `xyz.radicle.did` pin payload.
+ pub fn did_payload(&self) -> Result<Option<DidPayload>, PayloadError> {
+ match self.payload.get(&PayloadId::did()) {
+ None => Ok(None),
+ Some(payload) => {
+ let pins: DidPayload = serde_json::from_value((**payload).clone())?;
+ Ok(Some(pins))
+ }
+ }
+ }
+
+ /// Device [`PublicKey`]s that currently represent delegates for transport
+ /// purposes (namespaces, private-network allow sets).
+ ///
+ /// `did:key` delegates contribute themselves. `did:plc` delegates contribute
+ /// their pinned Ed25519 keys when present.
+ pub fn delegate_keys(&self) -> Vec<PublicKey> {
+ let pins = self.did_payload().ok().flatten().unwrap_or_default();
+ let mut keys = Vec::new();
+ for did in self.delegates.iter() {
+ match did {
+ Did::Key(key) => keys.push(*key),
+ Did::Plc(_) => {
+ if let Ok(Some(ks)) = pins.keys_for(did) {
+ keys.extend(ks);
+ }
+ }
+ }
+ }
+ keys.sort();
+ keys.dedup();
+ keys
+ }
+
/// Check whether this document and the associated repository is visible to
/// the given peer.
pub fn is_visible_to(&self, did: &Did) -> bool {
@@ -950,6 +1039,32 @@ impl Doc {
}
}
+ /// Whether the repository is visible to the device `key`.
+ pub fn is_visible_to_key(&self, key: &PublicKey) -> bool {
+ if self.is_visible_to(&Did::from(key)) {
+ return true;
+ }
+ match &self.visibility {
+ Visibility::Public => true,
+ Visibility::Private { allow } => {
+ if self.is_delegate_key(key) {
+ return true;
+ }
+ let Ok(Some(pins)) = self.did_payload() else {
+ return false;
+ };
+ allow.iter().any(|did| {
+ did.is_plc()
+ && pins
+ .keys_for(did)
+ .ok()
+ .flatten()
+ .is_some_and(|keys| keys.iter().any(|k| k == key))
+ })
+ }
+ }
+ }
+
/// Validate `signature` using this document's delegates, against a given
/// document blob.
pub fn verify_signature(
@@ -960,7 +1075,7 @@ impl Doc {
) -> Result<(), PublicKey> {
use crypto::signature::Verifier as _;
- if !self.is_delegate(&key.into()) {
+ if !self.is_delegate_key(key) {
return Err(*key);
}
@@ -1135,8 +1250,32 @@ mod test {
use crate::test::fixtures;
use super::*;
+ use crate::identity::plc::PlcId;
use qcheck_macros::quickcheck;
+ #[test]
+ fn test_plc_delegate_pin_and_visibility() {
+ let key = *SigningKey::mock(42).public_key();
+ let plc = PlcId::decode("did:plc:ewvi7nxzyoun6grtyetllrat").unwrap();
+ let did = Did::Plc(plc);
+ let mut raw = RawDoc::new(
+ r#gen::<Project>(1),
+ vec![Did::from(key)],
+ 1,
+ Visibility::Private {
+ allow: BTreeSet::from([did]),
+ },
+ );
+ raw.pin_did(did, [key]).unwrap();
+ let doc = raw.verified().unwrap();
+
+ assert!(doc.is_visible_to(&did));
+ assert!(doc.is_visible_to_key(&key));
+ assert!(doc.is_delegate_key(&key));
+ assert_eq!(doc.delegate_keys(), vec![key]);
+ assert!(doc.did_payload().unwrap().unwrap().get(&did).is_some());
+ }
+
#[test]
fn test_duplicate_dids() {
let delegate = SigningKey::mock(usize::MAX);
diff --git a/crates/radicle/src/identity/doc/update.rs b/crates/radicle/src/identity/doc/update.rs
index d04f8b54c3..1a3249e445 100644
--- a/crates/radicle/src/identity/doc/update.rs
+++ b/crates/radicle/src/identity/doc/update.rs
@@ -5,6 +5,7 @@ use std::{collections::BTreeSet, str::FromStr};
use serde_json as json;
use crate::{
+ crypto,
git,
prelude::Did,
storage::{self, ReadRepository, RepositoryError, refs},
@@ -253,24 +254,41 @@ fn verify_delegates(
proposal: &RawDoc,
repo: &storage::git::Repository,
) -> Result<Option<Vec<error::DelegateVerification>>, RepositoryError> {
+ use crate::identity::plc::DidPayload;
+
let dids = &proposal.delegates;
let threshold = proposal.threshold;
let (canonical, _) = repo.canonical_head()?;
let mut missing = Vec::with_capacity(dids.len());
+ let pins: DidPayload = proposal
+ .payload
+ .get(&super::PayloadId::did())
+ .and_then(|payload| serde_json::from_value((**payload).clone()).ok())
+ .unwrap_or_default();
for did in dids {
- match refs::SignedRefs::load((*did).into(), repo)
- .map_err(|err| storage::Error::Refs(storage::refs::Error::Read(err)))?
- {
- None => {
- missing.push(error::DelegateVerification::MissingDelegate { did: *did });
- }
- Some(sigrefs) => {
- if sigrefs.get(&canonical).is_none() {
- missing.push(error::DelegateVerification::MissingDefaultBranch {
- branch: canonical.to_ref_string(),
- did: *did,
- });
+ let nids: Vec<crypto::PublicKey> = match did.as_key() {
+ Some(key) => vec![*key],
+ None => pins.keys_for(did).ok().flatten().unwrap_or_default(),
+ };
+ if nids.is_empty() {
+ missing.push(error::DelegateVerification::MissingDelegate { did: *did });
+ continue;
+ }
+ for nid in nids {
+ match refs::SignedRefs::load(nid, repo)
+ .map_err(|err| storage::Error::Refs(storage::refs::Error::Read(err)))?
+ {
+ None => {
+ missing.push(error::DelegateVerification::MissingDelegate { did: *did });
+ }
+ Some(sigrefs) => {
+ if sigrefs.get(&canonical).is_none() {
+ missing.push(error::DelegateVerification::MissingDefaultBranch {
+ branch: canonical.to_ref_string(),
+ did: *did,
+ });
+ }
}
}
}
diff --git a/crates/radicle/src/identity/plc.rs b/crates/radicle/src/identity/plc.rs
new file mode 100644
index 0000000000..96ca1eaad3
--- /dev/null
+++ b/crates/radicle/src/identity/plc.rs
@@ -0,0 +1,545 @@
+//! ATProto `did:plc` identity support.
+//!
+//! Radicle keeps device transport IDs as Ed25519 `did:key` / [`crate::prelude::NodeId`].
+//! Logical actors may additionally be `did:plc` identifiers whose verifying keys are
+//! resolved via a hybrid model:
+//!
+//! - **Embedded pins** in the identity document (`xyz.radicle.did`) for authz-critical
+//! uses (delegates, private allow-lists).
+//! - **On-disk PLC cache** (optionally refreshed from `plc.directory`) for authorship
+//! attribution.
+//!
+//! Only Ed25519 Multikey verification methods are accepted (`#atproto` preferred,
+//! otherwise `#radicle`).
+
+use std::collections::BTreeMap;
+use std::fmt;
+use std::fs;
+use std::io;
+use std::path::{Path, PathBuf};
+use std::str::FromStr;
+
+use serde::{Deserialize, Serialize};
+use thiserror::Error;
+
+use crate::crypto::{PublicKey, PublicKeyError};
+use crate::identity::Did;
+
+/// Length of the identifier portion of a `did:plc` (base32lower, no padding).
+pub const PLC_ID_LEN: usize = 24;
+
+/// Validated `did:plc` identifier body (without the `did:plc:` prefix).
+#[derive(Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
+#[serde(transparent)]
+pub struct PlcId([u8; PLC_ID_LEN]);
+
+impl PlcId {
+ /// Encode as the full DID string `did:plc:…`.
+ pub fn encode(&self) -> String {
+ format!("did:plc:{}", self.as_str())
+ }
+
+ /// Identifier body as a UTF-8 string.
+ pub fn as_str(&self) -> &str {
+ // SAFETY: bytes are validated as ASCII base32lower on construction.
+ std::str::from_utf8(&self.0).expect("PlcId is valid UTF-8")
+ }
+
+ /// Decode a full `did:plc:…` string or bare identifier body.
+ pub fn decode(input: &str) -> Result<Self, PlcIdError> {
+ let body = input.strip_prefix("did:plc:").unwrap_or(input);
+ if body.len() != PLC_ID_LEN {
+ return Err(PlcIdError::Length(body.len()));
+ }
+ let mut bytes = [0u8; PLC_ID_LEN];
+ for (i, b) in body.bytes().enumerate() {
+ if !is_base32lower(b) {
+ return Err(PlcIdError::Alphabet(b));
+ }
+ bytes[i] = b;
+ }
+ Ok(Self(bytes))
+ }
+}
+
+fn is_base32lower(b: u8) -> bool {
+ matches!(b, b'a'..=b'z' | b'2'..=b'7')
+}
+
+impl fmt::Display for PlcId {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ f.write_str(&self.encode())
+ }
+}
+
+impl fmt::Debug for PlcId {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ write!(f, "PlcId({:?})", self.encode())
+ }
+}
+
+impl FromStr for PlcId {
+ type Err = PlcIdError;
+
+ fn from_str(s: &str) -> Result<Self, Self::Err> {
+ Self::decode(s)
+ }
+}
+
+#[derive(Error, Debug, PartialEq, Eq)]
+pub enum PlcIdError {
+ #[error("invalid did:plc length {0}, expected {PLC_ID_LEN}")]
+ Length(usize),
+ #[error("invalid did:plc character 0x{0:02x}, expected base32lower")]
+ Alphabet(u8),
+}
+
+/// Subset of a DID document used by Radicle.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct DidDocument {
+ pub id: String,
+ #[serde(default)]
+ pub also_known_as: Vec<String>,
+ #[serde(default)]
+ pub verification_method: Vec<VerificationMethod>,
+ #[serde(default)]
+ pub service: Vec<serde_json::Value>,
+}
+
+/// Multikey verification method entry.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct VerificationMethod {
+ pub id: String,
+ #[serde(rename = "type")]
+ pub type_: String,
+ pub controller: String,
+ pub public_key_multibase: String,
+}
+
+impl DidDocument {
+ /// Extract Ed25519 verifying keys accepted for Radicle.
+ ///
+ /// Prefers the first `#atproto` Multikey that is Ed25519; otherwise uses
+ /// `#radicle`. Other key types are ignored.
+ pub fn ed25519_keys(&self) -> Result<Vec<PublicKey>, DidDocumentError> {
+ let mut atproto = Vec::new();
+ let mut radicle = Vec::new();
+
+ for method in &self.verification_method {
+ if method.type_ != "Multikey" {
+ continue;
+ }
+ if method.controller != self.id {
+ continue;
+ }
+ let Ok(key) = PublicKey::from_str(&method.public_key_multibase) else {
+ continue;
+ };
+ if method.id.ends_with("#atproto") {
+ atproto.push(key);
+ } else if method.id.ends_with("#radicle") {
+ radicle.push(key);
+ }
+ }
+
+ let keys = if !atproto.is_empty() {
+ atproto
+ } else {
+ radicle
+ };
+
+ if keys.is_empty() {
+ Err(DidDocumentError::NoEd25519Key)
+ } else {
+ Ok(keys)
+ }
+ }
+}
+
+#[derive(Error, Debug)]
+pub enum DidDocumentError {
+ #[error("DID document has no Ed25519 #atproto or #radicle Multikey")]
+ NoEd25519Key,
+ #[error("json: {0}")]
+ Json(#[from] serde_json::Error),
+}
+
+/// Resolved verifying material for a DID.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct ResolvedDid {
+ pub did: Did,
+ pub keys: Vec<PublicKey>,
+ pub also_known_as: Vec<String>,
+}
+
+/// Errors from DID resolution.
+#[derive(Error, Debug)]
+pub enum ResolveError {
+ #[error("DID document: {0}")]
+ Document(#[from] DidDocumentError),
+ #[error("PLC id: {0}")]
+ PlcId(#[from] PlcIdError),
+ #[error("public key: {0}")]
+ PublicKey(#[from] PublicKeyError),
+ #[error("not found in cache or embed: {0}")]
+ NotFound(Did),
+ #[error("io: {0}")]
+ Io(#[from] io::Error),
+ #[error("http: {0}")]
+ Http(String),
+ #[error("json: {0}")]
+ Json(#[from] serde_json::Error),
+}
+
+/// Resolve logical DIDs to Ed25519 verifying keys.
+pub trait DidResolver {
+ fn resolve(&self, did: &Did) -> Result<ResolvedDid, ResolveError>;
+
+ /// Map a device signing key to a logical DID when a PLC binding is known.
+ fn actor_for_key(&self, key: &PublicKey) -> Did {
+ Did::Key(*key)
+ }
+}
+
+/// Resolver that only understands `did:key` (no PLC).
+#[derive(Debug, Default, Clone, Copy)]
+pub struct KeyOnlyResolver;
+
+impl DidResolver for KeyOnlyResolver {
+ fn resolve(&self, did: &Did) -> Result<ResolvedDid, ResolveError> {
+ match did {
+ Did::Key(key) => Ok(ResolvedDid {
+ did: Did::Key(*key),
+ keys: vec![*key],
+ also_known_as: vec![],
+ }),
+ Did::Plc(_) => Err(ResolveError::NotFound(*did)),
+ }
+ }
+}
+
+/// Pinned verifying keys for a DID, stored in the identity document payload.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct PinnedVerification {
+ /// Ed25519 keys as multibase (`z6Mk…`) or `did:key:…` strings.
+ pub keys: Vec<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub plc_cid: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub as_of: Option<String>,
+}
+
+impl PinnedVerification {
+ pub fn from_keys(keys: impl IntoIterator<Item = PublicKey>) -> Self {
+ Self {
+ keys: keys.into_iter().map(|k| k.to_human()).collect(),
+ plc_cid: None,
+ as_of: None,
+ }
+ }
+
+ pub fn public_keys(&self) -> Result<Vec<PublicKey>, PublicKeyError> {
+ self.keys
+ .iter()
+ .map(|s| {
+ let key = s.strip_prefix("did:key:").unwrap_or(s);
+ PublicKey::from_str(key)
+ })
+ .collect()
+ }
+}
+
+/// `xyz.radicle.did` payload: map of DID string → pinned verification.
+#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(transparent)]
+pub struct DidPayload {
+ pub pins: BTreeMap<String, PinnedVerification>,
+}
+
+impl DidPayload {
+ pub fn get(&self, did: &Did) -> Option<&PinnedVerification> {
+ self.pins.get(&did.encode())
+ }
+
+ pub fn insert(&mut self, did: Did, pin: PinnedVerification) {
+ self.pins.insert(did.encode(), pin);
+ }
+
+ pub fn keys_for(&self, did: &Did) -> Result<Option<Vec<PublicKey>>, PublicKeyError> {
+ match self.get(did) {
+ Some(pin) => pin.public_keys().map(Some),
+ None => Ok(None),
+ }
+ }
+
+ /// Find a PLC DID that lists `key` among its pinned verifying keys.
+ pub fn plc_for_key(&self, key: &PublicKey) -> Option<Did> {
+ let human = key.to_human();
+ let did_key = format!("did:key:{human}");
+ for (id, pin) in &self.pins {
+ if !id.starts_with("did:plc:") {
+ continue;
+ }
+ if pin.keys.iter().any(|k| k == &human || k == &did_key) {
+ if let Ok(did) = Did::decode(id) {
+ return Some(did);
+ }
+ }
+ }
+ None
+ }
+}
+
+/// On-disk PLC DID document cache under `~/.radicle/cache/plc/`.
+#[derive(Debug, Clone)]
+pub struct PlcCache {
+ path: PathBuf,
+ directory: String,
+}
+
+impl PlcCache {
+ pub const DEFAULT_DIRECTORY: &'static str = "https://plc.directory";
+
+ pub fn new(home: impl AsRef<Path>) -> Self {
+ Self {
+ path: home.as_ref().join("cache").join("plc"),
+ directory: Self::DEFAULT_DIRECTORY.to_owned(),
+ }
+ }
+
+ pub fn with_directory(mut self, directory: impl Into<String>) -> Self {
+ self.directory = directory.into();
+ self
+ }
+
+ pub fn path(&self) -> &Path {
+ &self.path
+ }
+
+ fn file_for(&self, id: &PlcId) -> PathBuf {
+ self.path.join(format!("{}.json", id.as_str()))
+ }
+
+ /// Load a cached DID document without network access.
+ pub fn load(&self, id: &PlcId) -> Result<Option<DidDocument>, ResolveError> {
+ let path = self.file_for(id);
+ match fs::read_to_string(&path) {
+ Ok(body) => Ok(Some(serde_json::from_str(&body)?)),
+ Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
+ Err(e) => Err(e.into()),
+ }
+ }
+
+ /// Store a DID document in the cache.
+ pub fn store(&self, id: &PlcId, doc: &DidDocument) -> Result<(), ResolveError> {
+ fs::create_dir_all(&self.path)?;
+ let path = self.file_for(id);
+ let body = serde_json::to_string_pretty(doc)?;
+ fs::write(path, body)?;
+ Ok(())
+ }
+
+ /// Fetch from the PLC directory and update the cache.
+ #[cfg(feature = "plc")]
+ pub fn fetch(&self, id: &PlcId) -> Result<DidDocument, ResolveError> {
+ let url = format!("{}/{}", self.directory.trim_end_matches('/'), id.encode());
+ let body = ureq::get(&url)
+ .call()
+ .map_err(|e| ResolveError::Http(e.to_string()))?
+ .into_string()
+ .map_err(|e| ResolveError::Http(e.to_string()))?;
+ let doc: DidDocument = serde_json::from_str(&body)?;
+ if doc.id != id.encode() {
+ return Err(ResolveError::Http(format!(
+ "DID document id mismatch: got {}, expected {}",
+ doc.id,
+ id.encode()
+ )));
+ }
+ self.store(id, &doc)?;
+ Ok(doc)
+ }
+
+ /// Resolve from cache, optionally refreshing via network when `fetch` is true.
+ pub fn resolve(&self, id: &PlcId, fetch: bool) -> Result<DidDocument, ResolveError> {
+ if let Some(doc) = self.load(id)? {
+ return Ok(doc);
+ }
+ #[cfg(feature = "plc")]
+ if fetch {
+ return self.fetch(id);
+ }
+ #[cfg(not(feature = "plc"))]
+ let _ = fetch;
+ Err(ResolveError::NotFound(Did::Plc(*id)))
+ }
+}
+
+/// Hybrid resolver: identity-doc pins first, then PLC cache, then `did:key`.
+#[derive(Debug, Clone)]
+pub struct HybridResolver {
+ cache: PlcCache,
+ pins: DidPayload,
+ /// Optional profile binding: local device key → PLC DID.
+ bindings: BTreeMap<PublicKey, Did>,
+ /// Attempt network fetch on cache miss when the `plc` feature is enabled.
+ fetch: bool,
+}
+
+impl HybridResolver {
+ pub fn new(cache: PlcCache) -> Self {
+ Self {
+ cache,
+ pins: DidPayload::default(),
+ bindings: BTreeMap::new(),
+ fetch: false,
+ }
+ }
+
+ pub fn with_pins(mut self, pins: DidPayload) -> Self {
+ self.pins = pins;
+ self
+ }
+
+ pub fn with_binding(mut self, key: PublicKey, did: Did) -> Self {
+ self.bindings.insert(key, did);
+ self
+ }
+
+ pub fn with_fetch(mut self, fetch: bool) -> Self {
+ self.fetch = fetch;
+ self
+ }
+
+ pub fn pins(&self) -> &DidPayload {
+ &self.pins
+ }
+
+ pub fn pins_mut(&mut self) -> &mut DidPayload {
+ &mut self.pins
+ }
+
+ pub fn cache(&self) -> &PlcCache {
+ &self.cache
+ }
+}
+
+impl DidResolver for HybridResolver {
+ fn resolve(&self, did: &Did) -> Result<ResolvedDid, ResolveError> {
+ match did {
+ Did::Key(key) => Ok(ResolvedDid {
+ did: Did::Key(*key),
+ keys: vec![*key],
+ also_known_as: vec![],
+ }),
+ Did::Plc(id) => {
+ if let Some(keys) = self.pins.keys_for(did)? {
+ return Ok(ResolvedDid {
+ did: *did,
+ keys,
+ also_known_as: vec![],
+ });
+ }
+ let doc = self.cache.resolve(id, self.fetch)?;
+ Ok(ResolvedDid {
+ did: *did,
+ keys: doc.ed25519_keys()?,
+ also_known_as: doc.also_known_as,
+ })
+ }
+ }
+ }
+
+ fn actor_for_key(&self, key: &PublicKey) -> Did {
+ if let Some(did) = self.bindings.get(key) {
+ return *did;
+ }
+ if let Some(did) = self.pins.plc_for_key(key) {
+ return did;
+ }
+ // Best-effort: scan cache directory for a document listing this key.
+ if let Ok(entries) = fs::read_dir(self.cache.path()) {
+ let human = key.to_human();
+ for entry in entries.flatten() {
+ let path = entry.path();
+ if path.extension().and_then(|e| e.to_str()) != Some("json") {
+ continue;
+ }
+ let Ok(body) = fs::read_to_string(&path) else {
+ continue;
+ };
+ let Ok(doc) = serde_json::from_str::<DidDocument>(&body) else {
+ continue;
+ };
+ if let Ok(keys) = doc.ed25519_keys() {
+ if keys.iter().any(|k| k.to_human() == human) {
+ if let Ok(plc) = PlcId::decode(&doc.id) {
+ return Did::Plc(plc);
+ }
+ }
+ }
+ }
+ }
+ Did::Key(*key)
+ }
+}
+
+/// Helper: whether `key` may act for `did` given a resolver.
+pub fn key_acts_for(resolver: &impl DidResolver, did: &Did, key: &PublicKey) -> bool {
+ match resolver.resolve(did) {
+ Ok(resolved) => resolved.keys.iter().any(|k| k == key),
+ Err(_) => match did {
+ Did::Key(k) => k == key,
+ Did::Plc(_) => false,
+ },
+ }
+}
+
+#[cfg(test)]
+#[allow(clippy::unwrap_used)]
+mod test {
+ use super::*;
+
+ #[test]
+ fn test_plc_id_roundtrip() {
+ let input = "did:plc:ewvi7nxzyoun6grtyetllrat";
+ let id = PlcId::decode(input).unwrap();
+ assert_eq!(id.encode(), input);
+ assert_eq!(id.as_str(), "ewvi7nxzyoun6grtyetllrat");
+ }
+
+ #[test]
+ fn test_plc_id_rejects_bad_alphabet() {
+ assert!(matches!(
+ PlcId::decode("did:plc:ewvi7nxzyoun6grtyetllra0"),
+ Err(PlcIdError::Alphabet(b'0'))
+ ));
+ }
+
+ #[test]
+ fn test_did_document_ed25519() {
+ let key = PublicKey::from_str("z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK").unwrap();
+ let doc = DidDocument {
+ id: "did:plc:ewvi7nxzyoun6grtyetllrat".into(),
+ also_known_as: vec!["at://alice.test".into()],
+ verification_method: vec![VerificationMethod {
+ id: "did:plc:ewvi7nxzyoun6grtyetllrat#atproto".into(),
+ type_: "Multikey".into(),
+ controller: "did:plc:ewvi7nxzyoun6grtyetllrat".into(),
+ public_key_multibase: key.to_human(),
+ }],
+ service: vec![],
+ };
+ assert_eq!(doc.ed25519_keys().unwrap(), vec![key]);
+ }
+
+ #[test]
+ fn test_pinned_verification_roundtrip() {
+ let key = PublicKey::from_str("z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK").unwrap();
+ let pin = PinnedVerification::from_keys([key]);
+ assert_eq!(pin.public_keys().unwrap(), vec![key]);
+ }
+}
diff --git a/crates/radicle/src/node/policy/config.rs b/crates/radicle/src/node/policy/config.rs
index ec673d51ce..e7b74ff353 100644
--- a/crates/radicle/src/node/policy/config.rs
+++ b/crates/radicle/src/node/policy/config.rs
@@ -5,7 +5,6 @@ use std::ops;
use log::error;
use thiserror::Error;
-use crate::crypto::PublicKey;
use crate::prelude::RepoId;
use crate::storage::{Namespaces, ReadRepository as _, ReadStorage, RepositoryError};
@@ -125,8 +124,12 @@ impl<T> Config<T> {
let delegates = repo
.delegates()
.map_err(|err| FailedDelegates { rid: *rid, err })?
- .map(PublicKey::from);
+ .into_iter()
+ .filter_map(|did| did.as_key().copied());
followed.extend(delegates);
+ if let Ok(doc) = repo.identity_doc() {
+ followed.extend(doc.delegate_keys());
+ }
};
if followed.is_empty() {
// Nb. returning All here because the
diff --git a/crates/radicle/src/node/sync.rs b/crates/radicle/src/node/sync.rs
index f455eea690..994c22e335 100644
--- a/crates/radicle/src/node/sync.rs
+++ b/crates/radicle/src/node/sync.rs
@@ -30,7 +30,8 @@ impl PrivateNetwork {
.delegates()
.iter()
.chain(allow.iter())
- .map(|did| *did.as_key())
+ .filter_map(|did| did.as_key().copied())
+ .chain(doc.delegate_keys())
.collect();
Some(Self { allowed })
}
diff --git a/crates/radicle/src/profile.rs b/crates/radicle/src/profile.rs
index 513900844f..ab449e539a 100644
--- a/crates/radicle/src/profile.rs
+++ b/crates/radicle/src/profile.rs
@@ -321,6 +321,70 @@ impl Profile {
Did::from(self.public_key)
}
+ /// Path to the optional ATProto PLC binding file.
+ pub fn plc_link_path(&self) -> PathBuf {
+ self.home.path().join("plc.link")
+ }
+
+ /// Local PLC DID cache directory helper.
+ pub fn plc_cache(&self) -> crate::identity::PlcCache {
+ crate::identity::PlcCache::new(self.home.path())
+ }
+
+ /// Linked ATProto PLC DID, if configured.
+ pub fn plc(&self) -> Option<crate::identity::PlcId> {
+ let path = self.plc_link_path();
+ let Ok(body) = fs::read_to_string(path) else {
+ return None;
+ };
+ #[derive(serde::Deserialize)]
+ struct Link {
+ did: String,
+ }
+ let link: Link = serde_json::from_str(&body).ok()?;
+ crate::identity::PlcId::decode(&link.did).ok()
+ }
+
+ /// Link this device key to a `did:plc`, verifying the local Ed25519 key is
+ /// listed as `#atproto` or `#radicle` in the DID document (from cache or
+ /// fetch when the `plc` feature is enabled).
+ pub fn link_plc(&self, id: crate::identity::PlcId) -> Result<(), Error> {
+ let cache = self.plc_cache();
+ #[cfg(feature = "plc")]
+ let doc = cache.fetch(&id).map_err(|e| {
+ Error::Io(io::Error::other(format!("failed to resolve {id}: {e}")))
+ })?;
+ #[cfg(not(feature = "plc"))]
+ let doc = cache.resolve(&id, false).map_err(|e| {
+ Error::Io(io::Error::other(format!(
+ "failed to resolve {id} from cache: {e}"
+ )))
+ })?;
+ let keys = doc.ed25519_keys().map_err(|e| {
+ Error::Io(io::Error::other(format!("DID document for {id}: {e}")))
+ })?;
+ if !keys.iter().any(|k| k == &self.public_key) {
+ return Err(Error::Io(io::Error::other(format!(
+ "local device key is not an Ed25519 #atproto/#radicle key for {id}"
+ ))));
+ }
+ let body = serde_json::json!({ "did": id.encode() });
+ fs::write(
+ self.plc_link_path(),
+ serde_json::to_string_pretty(&body).map_err(|e| Error::Io(io::Error::other(e)))?,
+ )?;
+ Ok(())
+ }
+
+ /// Hybrid DID resolver using the local PLC cache and optional PLC binding.
+ pub fn did_resolver(&self) -> crate::identity::HybridResolver {
+ let mut resolver = crate::identity::HybridResolver::new(self.plc_cache());
+ if let Some(plc) = self.plc() {
+ resolver = resolver.with_binding(self.public_key, Did::Plc(plc));
+ }
+ resolver
+ }
+
pub fn signer(&self) -> Result<Signer, SignerError> {
/// Where to obtain the signer from.
enum Source {
diff --git a/crates/radicle/src/storage/git.rs b/crates/radicle/src/storage/git.rs
index 956a013fc5..ccb5bb7f2b 100644
--- a/crates/radicle/src/storage/git.rs
+++ b/crates/radicle/src/storage/git.rs
@@ -478,7 +478,11 @@ impl Repository {
let delegates = self
.delegates()?
.into_iter()
- .map(|did| *did)
+ .filter_map(|did| did.as_key().copied())
+ .chain({
+ let doc = self.identity_doc()?;
+ doc.delegate_keys()
+ })
.collect::<BTreeSet<_>>();
let mut deleted = Vec::new();
for id in self.remote_ids()? {
Exit code: 0
shell: 'export RUSTDOCFLAGS=''-D warnings'' cargo --version rustc --version cargo fmt --check cargo clippy --all-targets --workspace -- --deny warnings cargo build --all-targets --workspace cargo doc --workspace --no-deps --all-features cargo test --workspace --no-fail-fast '
Commands:
$ podman run --name ba904a55-5fdf-42f5-99fb-48ae6d760e64 -v /opt/radcis/ci.rad.levitte.org/cci/state/ba904a55-5fdf-42f5-99fb-48ae6d760e64/s:/ba904a55-5fdf-42f5-99fb-48ae6d760e64/s:ro -v /opt/radcis/ci.rad.levitte.org/cci/state/ba904a55-5fdf-42f5-99fb-48ae6d760e64/w:/ba904a55-5fdf-42f5-99fb-48ae6d760e64/w -w /ba904a55-5fdf-42f5-99fb-48ae6d760e64/w -v /opt/radcis/ci.rad.levitte.org/.radicle:/${id}/.radicle:ro -e RAD_HOME=/${id}/.radicle rust:trixie bash /ba904a55-5fdf-42f5-99fb-48ae6d760e64/s/script.sh
+ export 'RUSTDOCFLAGS=-D warnings'
+ RUSTDOCFLAGS='-D warnings'
+ cargo --version
info: syncing channel updates for '1.97.1-x86_64-unknown-linux-gnu'
info: latest update on 2026-07-16, rust version 1.97.1 (8bab26f4f 2026-07-14)
info: downloading component 'cargo'
info: downloading component 'clippy'
info: downloading component 'rust-docs'
info: downloading component 'rust-src'
info: downloading component 'rust-std'
info: downloading component 'rustc'
info: downloading component 'rustfmt'
info: installing component 'cargo'
info: installing component 'clippy'
info: installing component 'rust-docs'
info: installing component 'rust-src'
info: installing component 'rust-std'
info: installing component 'rustc'
info: installing component 'rustfmt'
cargo 1.97.1 (c980f4866 2026-06-30)
+ rustc --version
rustc 1.97.1 (8bab26f4f 2026-07-14)
+ cargo fmt --check
Diff in /ba904a55-5fdf-42f5-99fb-48ae6d760e64/w/crates/radicle/src/cob/issue.rs:1255:
let reactions = issue.comment(&comment).unwrap().reactions();
let authors = reactions.get(&reaction).unwrap();
- assert_eq!(**authors.first().unwrap(), Did::from(*node.signer.public_key()));
+ assert_eq!(
+ **authors.first().unwrap(),
+ Did::from(*node.signer.public_key())
+ );
// TODO: Test multiple reactions from same author and different authors
}
Diff in /ba904a55-5fdf-42f5-99fb-48ae6d760e64/w/crates/radicle/src/cob/op.rs:10:
use crate::crypto;
use crate::git;
use crate::identity;
-use crate::identity::DocAt;
use crate::identity::Did;
+use crate::identity::DocAt;
use crate::identity::plc::{DidResolver, KeyOnlyResolver};
use crate::storage::ReadRepository;
Diff in /ba904a55-5fdf-42f5-99fb-48ae6d760e64/w/crates/radicle/src/cob/patch.rs:3560:
let (_, revision) = patch.latest();
assert_eq!(revision.reviews.len(), 1);
- let review = revision.review_by(&Did::from(*alice.signer.public_key())).unwrap();
+ let review = revision
+ .review_by(&Did::from(*alice.signer.public_key()))
+ .unwrap();
assert_eq!(review.verdict(), Some(Verdict::Accept));
assert_eq!(review.summary(), "LGTM");
Diff in /ba904a55-5fdf-42f5-99fb-48ae6d760e64/w/crates/radicle/src/cob/patch.rs:3797:
.unwrap(); // Overwrite the comment.
let (_, revision) = patch.latest();
- let review = revision.review_by(&Did::from(*alice.signer.public_key())).unwrap();
+ let review = revision
+ .review_by(&Did::from(*alice.signer.public_key()))
+ .unwrap();
assert_eq!(review.verdict(), Some(Verdict::Reject));
assert_eq!(review.summary(), "Whoops!");
}
Diff in /ba904a55-5fdf-42f5-99fb-48ae6d760e64/w/crates/radicle/src/cob/patch.rs:3828:
.unwrap(); // This review is ignored, since there is already a review by this author.
let (_, revision) = patch.latest();
- let review = revision.review_by(&Did::from(*alice.signer.public_key())).unwrap();
+ let review = revision
+ .review_by(&Did::from(*alice.signer.public_key()))
+ .unwrap();
assert_eq!(review.verdict(), Some(Verdict::Accept));
}
Diff in /ba904a55-5fdf-42f5-99fb-48ae6d760e64/w/crates/radicle/src/cob/patch.rs:3871:
.unwrap();
let (_, revision) = patch.latest();
- let review = revision.review_by(&Did::from(*alice.signer.public_key())).unwrap();
+ let review = revision
+ .review_by(&Did::from(*alice.signer.public_key()))
+ .unwrap();
assert_eq!(review.verdict(), Some(Verdict::Reject));
assert_eq!(review.comments().count(), 2);
assert_eq!(review.comments().next().unwrap().1.body(), "First comment!");
Diff in /ba904a55-5fdf-42f5-99fb-48ae6d760e64/w/crates/radicle/src/cob/patch.rs:3919:
.unwrap();
let (_, revision) = patch.latest();
- let review = revision.review_by(&Did::from(*alice.signer.public_key())).unwrap();
+ let review = revision
+ .review_by(&Did::from(*alice.signer.public_key()))
+ .unwrap();
let (_, comment) = review.comments().next().unwrap();
assert_eq!(comment.body(), "I like these lines of code");
Diff in /ba904a55-5fdf-42f5-99fb-48ae6d760e64/w/crates/radicle/src/cob/patch.rs:3960:
let id = patch.id;
let patch = patches.get_mut(&id).unwrap();
let (_, revision) = patch.latest();
- let review = revision.review_by(&Did::from(*alice.signer.public_key())).unwrap();
+ let review = revision
+ .review_by(&Did::from(*alice.signer.public_key()))
+ .unwrap();
assert_eq!(review.verdict(), Some(Verdict::Accept));
assert_eq!(review.summary(), "");
Diff in /ba904a55-5fdf-42f5-99fb-48ae6d760e64/w/crates/radicle/src/git/canonical.rs:496:
impl Missing {
fn found<'a>(&mut self, did: &Did, refname: &Qualified<'a>) {
self.objects.remove(did);
- self.refs
- .remove(
- &refname
- .with_namespace(
- (did.as_key()
- .expect("canonical rules only reference did:key namespaces"))
- .into(),
- )
- .to_owned(),
- );
+ self.refs.remove(
+ &refname
+ .with_namespace(
+ (did.as_key()
+ .expect("canonical rules only reference did:key namespaces"))
+ .into(),
+ )
+ .to_owned(),
+ );
}
}
Diff in /ba904a55-5fdf-42f5-99fb-48ae6d760e64/w/crates/radicle/src/identity/did.rs:55:
.map_err(DidError::from);
}
if input.starts_with("did:plc:") {
- return PlcId::decode(input)
- .map(Self::Plc)
- .map_err(DidError::from);
+ return PlcId::decode(input).map(Self::Plc).map_err(DidError::from);
}
if let Ok(key) = crypto::PublicKey::from_str(input) {
return Ok(Self::Key(key));
Diff in /ba904a55-5fdf-42f5-99fb-48ae6d760e64/w/crates/radicle/src/identity/doc/update.rs:5:
use serde_json as json;
use crate::{
- crypto,
- git,
+ crypto, git,
prelude::Did,
storage::{self, ReadRepository, RepositoryError, refs},
};
Diff in /ba904a55-5fdf-42f5-99fb-48ae6d760e64/w/crates/radicle/src/identity/doc.rs:250:
pub fn did() -> Self {
Self(
// SAFETY: We know this is valid.
- TypeName::from_str("xyz.radicle.did")
- .expect("PayloadId::did: type name is valid"),
+ TypeName::from_str("xyz.radicle.did").expect("PayloadId::did: type name is valid"),
)
}
}
Diff in /ba904a55-5fdf-42f5-99fb-48ae6d760e64/w/crates/radicle/src/profile.rs:351:
pub fn link_plc(&self, id: crate::identity::PlcId) -> Result<(), Error> {
let cache = self.plc_cache();
#[cfg(feature = "plc")]
- let doc = cache.fetch(&id).map_err(|e| {
- Error::Io(io::Error::other(format!("failed to resolve {id}: {e}")))
- })?;
+ let doc = cache
+ .fetch(&id)
+ .map_err(|e| Error::Io(io::Error::other(format!("failed to resolve {id}: {e}"))))?;
#[cfg(not(feature = "plc"))]
let doc = cache.resolve(&id, false).map_err(|e| {
Error::Io(io::Error::other(format!(
Diff in /ba904a55-5fdf-42f5-99fb-48ae6d760e64/w/crates/radicle/src/profile.rs:360:
"failed to resolve {id} from cache: {e}"
)))
})?;
- let keys = doc.ed25519_keys().map_err(|e| {
- Error::Io(io::Error::other(format!("DID document for {id}: {e}")))
- })?;
+ let keys = doc
+ .ed25519_keys()
+ .map_err(|e| Error::Io(io::Error::other(format!("DID document for {id}: {e}"))))?;
if !keys.iter().any(|k| k == &self.public_key) {
return Err(Error::Io(io::Error::other(format!(
"local device key is not an Ed25519 #atproto/#radicle key for {id}"
Diff in /ba904a55-5fdf-42f5-99fb-48ae6d760e64/w/crates/radicle-cli/src/terminal/format.rs:264:
let did = id.into();
let (alias, you) = match did.as_key() {
Some(nid) => (profile.alias(nid), nid == profile.id()),
- None => (
- None,
- profile.plc().is_some_and(|plc| Did::from(plc) == did),
- ),
+ None => (None, profile.plc().is_some_and(|plc| Did::from(plc) == did)),
};
Self {
Diff in /ba904a55-5fdf-42f5-99fb-48ae6d760e64/w/crates/radicle-cli/src/terminal/patch/timeline.rs:108:
}
}
- fn into_lines(
- self,
- profile: &Profile,
- verbose: bool,
- ) -> impl Iterator<Item = term::Line> + '_ {
+ fn into_lines(self, profile: &Profile, verbose: bool) -> impl Iterator<Item = term::Line> + '_ {
use term::{format::*, *};
let id: Label = if verbose {
Exit code: 1
{
"response": "finished",
"result": "failure"
}