cob: Introduce xyz.radicle.id COB

Introduces a new COB type to store repository identity documents.

The reason for this change is to:

1. Simplify the code, as the identity document logic resembled a COB,
   yet it had custom logic. This allows existing COB code to be used for
   identities.
2. Make identity document update logic more flexible, since COB actions
   can be added in the future.
3. Re-purpose existing tools around COBs to work on identities, eg. `rad
   cob`.
4. Unify the concept of an identity change proposal, with regular identity
   changes. This means we can remove the `id.proposal` COB in favor of
   using the `id` COB itself.

Notes
-----

* Each repository has one Identity COB.
* The `Proposal` COB has been repurposed into the `Identity` COB.
* Identity documents are stored as *embeds* inside the Identity COB
  actions.
* The action that contains new document versions is called `revision`,
  just like the Patches COB.
* The namespaced `rad/id` ref is a symbolic reference to that Identity
  COB.
* The canonical `rad/id` ref is a direct reference to the commit in the
  Identity COB that contains the latest *accepted* revision of the
  document.
* All commands for managing identities have been folded into `rad id`.
  Hence `rad delegate` and `rad edit` are removed.
* The concept of "rebasing" an identity document is gone.
* The `rad id` output has been updated to match the style of other
  commands.
* When a revision has enough signatures, it is automatically adopted as
  the current identity, there is no longer the need for a "commit"
  action.
* When an identity revision is proposed, and the current identity has a
  threshold of `1`, that identity is automatically accepted due to the
  above point.
* The idea of "verifying a peer's identity branch" no longer applies, as
  COBs cannot be verified one history at a time.
* Since the root commit of the Identity COB does not have a "Resource"
  to point to (it would have to point to itself, which is impossible),
  we've made the resource id optional for COBs.
This commit is contained in:
cloudhead
2023-10-12 17:32:33 +02:00
parent 120c109d0d
commit 472f38bacf
6 changed files with 100 additions and 121 deletions
+2 -2
View File
@@ -17,7 +17,7 @@ use tower_http::cors::{self, CorsLayer};
use radicle::cob::patch;
use radicle::cob::{issue, Uri};
use radicle::identity::Id;
use radicle::identity::{DocAt, Id};
use radicle::node::routing::Store;
use radicle::storage::{ReadRepository, ReadStorage};
use radicle::Profile;
@@ -54,7 +54,7 @@ impl Context {
let storage = &self.profile.storage;
let repo = storage.repository(id)?;
let (_, head) = repo.head()?;
let doc = repo.identity_doc()?.1.verified()?;
let DocAt { doc, .. } = repo.identity_doc()?;
let payload = doc.project()?;
let delegates = doc.delegates;
let issues = issue::Issues::open(&repo)?.counts()?;
+3 -6
View File
@@ -42,9 +42,9 @@ pub enum Error {
#[error(transparent)]
CobStore(#[from] radicle::cob::store::Error),
/// Identity error.
/// Repository error.
#[error(transparent)]
Identity(#[from] radicle::identity::IdentityError),
Repository(#[from] radicle::storage::RepositoryError),
/// Project doc error.
#[error(transparent)]
@@ -115,10 +115,7 @@ impl IntoResponse for Error {
tracing::error!("Error: {message}");
if cfg!(debug_assertions) {
(
StatusCode::INTERNAL_SERVER_ERROR,
Some(format!("{other:?}")),
)
(StatusCode::INTERNAL_SERVER_ERROR, Some(other.to_string()))
} else {
(StatusCode::INTERNAL_SERVER_ERROR, None)
}
+4 -5
View File
@@ -5,7 +5,7 @@ use axum::{Json, Router};
use radicle::cob::issue::Issues;
use radicle::cob::patch::Patches;
use radicle::identity::Did;
use radicle::identity::{Did, DocAt};
use radicle::node::routing::Store;
use radicle::storage::{ReadRepository, ReadStorage};
@@ -42,12 +42,10 @@ async fn delegates_projects_handler(
.filter_map(|id| {
let Ok(repo) = storage.repository(id) else { return None };
let Ok((_, head)) = repo.head() else { return None };
let Ok((_, doc)) = repo.identity_doc() else { return None };
let Ok(doc) = doc.verified() else { return None };
let Ok(DocAt { doc, .. }) = repo.identity_doc() else { return None };
let Ok(payload) = doc.project() else { return None };
let delegates = doc.delegates;
if !delegates.iter().any(|d| *d == delegate) {
if !doc.delegates.iter().any(|d| *d == delegate) {
return None;
}
@@ -56,6 +54,7 @@ async fn delegates_projects_handler(
let Ok(patches) = Patches::open(&repo) else { return None };
let Ok(patches) = patches.counts() else { return None };
let delegates = doc.delegates;
let trackings = routing.count(&id).unwrap_or_default();
Some(Info {
+64 -96
View File
@@ -14,7 +14,7 @@ use serde_json::{json, Value};
use tower_http::set_header::SetResponseHeaderLayer;
use radicle::cob::{issue, patch, Embed, Label, Uri};
use radicle::identity::{Did, Id};
use radicle::identity::{Did, DocAt, Id};
use radicle::node::routing::Store;
use radicle::node::AliasStore;
use radicle::node::NodeId;
@@ -89,8 +89,7 @@ async fn project_root_handler(
.filter_map(|id| {
let Ok(repo) = storage.repository(id) else { return None };
let Ok((_, head)) = repo.head() else { return None };
let Ok((_, doc)) = repo.identity_doc() else { return None };
let Ok(doc) = doc.verified() else { return None };
let Ok(DocAt { doc, .. }) = repo.identity_doc() else { return None };
let Ok(payload) = doc.project() else { return None };
let Ok(issues) = issue::Issues::open(&repo) else { return None };
let Ok(issues) = issues.counts() else { return None };
@@ -686,24 +685,16 @@ async fn issue_update_handler(
api::auth::validate(&ctx, &token).await?;
let storage = &ctx.profile.storage;
let signer = ctx.profile.signer().unwrap();
let signer = ctx.profile.signer()?;
let repo = storage.repository(project)?;
let mut issues = issue::Issues::open(&repo)?;
let mut issue = issues.get_mut(&issue_id.into())?;
match action {
issue::Action::Assign { assignees } => {
issue.assign(assignees, &signer)?;
}
issue::Action::Lifecycle { state } => {
issue.lifecycle(state, &signer)?;
}
issue::Action::Label { labels } => {
issue.label(labels, &signer)?;
}
issue::Action::Edit { title } => {
issue.edit(title, &signer)?;
}
let id = match action {
issue::Action::Assign { assignees } => issue.assign(assignees, &signer)?,
issue::Action::Lifecycle { state } => issue.lifecycle(state, &signer)?,
issue::Action::Label { labels } => issue.label(labels, &signer)?,
issue::Action::Edit { title } => issue.edit(title, &signer)?,
issue::Action::Comment {
body,
reply_to,
@@ -720,7 +711,7 @@ async fn issue_update_handler(
})
.collect();
if let Some(to) = reply_to {
issue.comment(body, to, embeds, &signer)?;
issue.comment(body, to, embeds, &signer)?
} else {
return Err(Error::BadRequest("`replyTo` missing".to_owned()));
}
@@ -729,9 +720,7 @@ async fn issue_update_handler(
id,
reaction,
active,
} => {
issue.react(id, reaction, active, &signer)?;
}
} => issue.react(id, reaction, active, &signer)?,
issue::Action::CommentEdit { id, body, embeds } => {
let embeds: Vec<Embed> = embeds
.into_iter()
@@ -743,14 +732,12 @@ async fn issue_update_handler(
})
})
.collect();
issue.edit_comment(id, body, embeds, &signer)?;
}
issue::Action::CommentRedact { id } => {
issue.redact_comment(id, &signer)?;
issue.edit_comment(id, body, embeds, &signer)?
}
issue::Action::CommentRedact { id } => issue.redact_comment(id, &signer)?,
};
Ok::<_, Error>(Json(json!({ "success": true })))
Ok::<_, Error>(Json(json!({ "success": true, "id": id })))
}
/// Get project issue.
@@ -830,41 +817,27 @@ async fn patch_update_handler(
let repo = storage.repository(project)?;
let mut patches = patch::Patches::open(&repo)?;
let mut patch = patches.get_mut(&patch_id.into())?;
match action {
patch::Action::Edit { title, target } => {
patch.edit(title, target, &signer)?;
}
patch::Action::Label { labels } => {
patch.label(labels, &signer)?;
}
patch::Action::Lifecycle { state } => {
patch.lifecycle(state, &signer)?;
}
patch::Action::Assign { assignees } => {
patch.assign(assignees, &signer)?;
}
let id = match action {
patch::Action::Edit { title, target } => patch.edit(title, target, &signer)?,
patch::Action::Label { labels } => patch.label(labels, &signer)?,
patch::Action::Lifecycle { state } => patch.lifecycle(state, &signer)?,
patch::Action::Assign { assignees } => patch.assign(assignees, &signer)?,
patch::Action::Merge { revision, commit } => {
// TODO: We should cleanup the stored copy at least.
let _ = patch.merge(revision, commit, &signer)?;
patch.merge(revision, commit, &signer)?.entry
}
patch::Action::Review {
revision,
summary,
verdict,
labels,
} => {
patch.review(revision, verdict, summary, labels, &signer)?;
}
} => *patch.review(revision, verdict, summary, labels, &signer)?,
patch::Action::ReviewEdit {
review,
summary,
verdict,
} => {
patch.edit_review(review, summary, verdict, &signer)?;
}
patch::Action::ReviewRedact { review } => {
patch.redact_review(review, &signer)?;
}
} => patch.edit_review(review, summary, verdict, &signer)?,
patch::Action::ReviewRedact { review } => patch.redact_review(review, &signer)?,
patch::Action::ReviewComment {
review,
body,
@@ -882,7 +855,7 @@ async fn patch_update_handler(
})
})
.collect();
patch.review_comment(review, body, location, reply_to, embeds, &signer)?;
patch.review_comment(review, body, location, reply_to, embeds, &signer)?
}
patch::Action::ReviewCommentEdit {
review,
@@ -900,42 +873,34 @@ async fn patch_update_handler(
})
})
.collect();
patch.edit_review_comment(review, comment, body, embeds, &signer)?;
patch.edit_review_comment(review, comment, body, embeds, &signer)?
}
patch::Action::ReviewCommentReact {
review,
comment,
reaction,
active,
} => {
patch.react_review_comment(review, comment, reaction, active, &signer)?;
}
} => patch.react_review_comment(review, comment, reaction, active, &signer)?,
patch::Action::ReviewCommentRedact { review, comment } => {
patch.redact_review_comment(review, comment, &signer)?;
patch.redact_review_comment(review, comment, &signer)?
}
patch::Action::ReviewCommentResolve { review, comment } => {
patch.resolve_review_comment(review, comment, &signer)?;
patch.resolve_review_comment(review, comment, &signer)?
}
patch::Action::ReviewCommentUnresolve { review, comment } => {
patch.unresolve_review_comment(review, comment, &signer)?;
patch.unresolve_review_comment(review, comment, &signer)?
}
patch::Action::Revision {
description,
base,
oid,
..
} => {
patch.update(description, base, oid, &signer)?;
}
} => patch.update(description, base, oid, &signer)?.into(),
patch::Action::RevisionEdit {
revision,
description,
} => {
patch.edit_revision(revision, description, &signer)?;
}
patch::Action::RevisionRedact { revision } => {
patch.redact(revision, &signer)?;
}
} => patch.edit_revision(revision, description, &signer)?,
patch::Action::RevisionRedact { revision } => patch.redact(revision, &signer)?,
patch::Action::RevisionComment {
revision,
body,
@@ -953,7 +918,7 @@ async fn patch_update_handler(
})
})
.collect();
patch.comment(revision, body, reply_to, location, embeds, &signer)?;
patch.comment(revision, body, reply_to, location, embeds, &signer)?
}
patch::Action::RevisionCommentEdit {
revision,
@@ -971,25 +936,23 @@ async fn patch_update_handler(
})
})
.collect();
patch.comment_edit(revision, comment, body, embeds, &signer)?;
patch.comment_edit(revision, comment, body, embeds, &signer)?
}
patch::Action::RevisionCommentReact {
revision,
comment,
reaction,
active,
} => {
patch.comment_react(revision, comment, reaction, active, &signer)?;
}
} => patch.comment_react(revision, comment, reaction, active, &signer)?,
patch::Action::RevisionCommentRedact { revision, comment } => {
patch.comment_redact(revision, comment, &signer)?;
patch.comment_redact(revision, comment, &signer)?
}
_ => {
todo!();
}
};
Ok::<_, Error>(Json(json!({ "success": true })))
Ok::<_, Error>(Json(json!({ "success": true, "id": id })))
}
/// Get project patches list.
@@ -2058,7 +2021,7 @@ mod routes {
#[tokio::test]
async fn test_projects_issues_create() {
const CREATED_ISSUE_ID: &str = "e712eb0b5874d5256022fb620f26caf847d96723";
const CREATED_ISSUE_ID: &str = "8b42657072f6192cba9e08561582576a975656cd";
let tmp = tempfile::tempdir().unwrap();
let ctx = contributor(tmp.path());
@@ -2166,7 +2129,7 @@ mod routes {
.await;
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.json().await, json!({ "success": true }));
assert_eq!(response.success().await, true);
let body = serde_json::to_vec(&json!({
"type": "comment.react",
@@ -2199,11 +2162,11 @@ mod routes {
)
.await;
assert_eq!(response.json().await, json!({ "success": true }));
assert_eq!(response.success().await, true);
let body = serde_json::to_vec(&json!({
"type": "comment.redact",
"id": "6fe9fdec2ec9f6436f2875dbcbedb95dd215b863",
"id": "918fff44966e4305523c077c80ac93b1196f1a7e",
}))
.unwrap();
@@ -2215,7 +2178,7 @@ mod routes {
)
.await;
assert_eq!(response.json().await, json!({ "success": true }));
assert_eq!(response.success().await, true);
let response = get(
&app,
@@ -2284,7 +2247,7 @@ mod routes {
.await;
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.json().await, json!({ "success": true }));
assert_eq!(response.success().await, true);
let response = get(
&app,
@@ -2423,7 +2386,7 @@ mod routes {
#[tokio::test]
async fn test_projects_create_patches() {
const CREATED_PATCH_ID: &str = "9cffd66099cceb0439a0f67c4aa99bde5e868eaa";
const CREATED_PATCH_ID: &str = "beaed2e1d3b9b01ef10326a9a1c951799ba5fb25";
let tmp = tempfile::tempdir().unwrap();
let ctx = contributor(tmp.path());
@@ -2686,7 +2649,7 @@ mod routes {
"reviews": [],
},
{
"id": "b1f68feacb7040b089a77c1a0bff60a0411e6c1e",
"id": "341ba93c6db54e5891fbd3be4a4f64f4715681fa",
"author": {
"id": CONTRIBUTOR_DID,
},
@@ -2857,10 +2820,12 @@ mod routes {
.await;
assert_eq!(response.status(), StatusCode::OK);
let comment_id = response.id().await.to_string();
let comment_react_body = serde_json::to_vec(&json!({
"type": "revision.comment.react",
"revision": CONTRIBUTOR_PATCH_ID,
"comment": CONTRIBUTOR_COMMENT_1,
"comment": comment_id,
"reaction": "🚀",
"active": true
}))
@@ -2876,7 +2841,7 @@ mod routes {
let comment_edit = serde_json::to_vec(&json!({
"type": "revision.comment.edit",
"revision": CONTRIBUTOR_PATCH_ID,
"comment": CONTRIBUTOR_COMMENT_1,
"comment": comment_id,
"body": "EDIT: This is a root level comment",
"embeds": [
{
@@ -2899,7 +2864,7 @@ mod routes {
"type": "revision.comment",
"revision": CONTRIBUTOR_PATCH_ID,
"body": "This is a root level comment",
"replyTo": CONTRIBUTOR_COMMENT_1,
"replyTo": comment_id,
"embeds": [],
}))
.unwrap();
@@ -2912,6 +2877,7 @@ mod routes {
.await;
assert_eq!(response.status(), StatusCode::OK);
let comment_id_2 = response.id().await.to_string();
let response = get(
&app,
@@ -2946,7 +2912,7 @@ mod routes {
],
"discussions": [
{
"id": CONTRIBUTOR_COMMENT_1,
"id": comment_id,
"author": {
"id": CONTRIBUTOR_DID,
},
@@ -2963,7 +2929,7 @@ mod routes {
"resolved": false,
},
{
"id": CONTRIBUTOR_COMMENT_2,
"id": comment_id_2,
"author": {
"id": CONTRIBUTOR_DID,
},
@@ -2971,7 +2937,7 @@ mod routes {
"embeds": [],
"reactions": [],
"timestamp": TIMESTAMP,
"replyTo": CONTRIBUTOR_COMMENT_1,
"replyTo": comment_id,
"resolved": false,
},
],
@@ -3006,9 +2972,10 @@ mod routes {
assert_eq!(response.status(), StatusCode::OK);
let review_id = response.id().await.to_string();
let review_comment_body = serde_json::to_vec(&json!({
"type": "review.comment",
"review": CONTRIBUTOR_PATCH_REVIEW,
"review": review_id,
"body": "This is a comment on a review",
"embeds": [],
"location": {
@@ -3023,7 +2990,7 @@ mod routes {
}
}))
.unwrap();
patch(
let response = patch(
&app,
format!("/projects/{CONTRIBUTOR_RID}/patches/{CONTRIBUTOR_PATCH_ID}"),
Some(Body::from(review_comment_body)),
@@ -3031,10 +2998,11 @@ mod routes {
)
.await;
let comment_id = response.id().await.to_string();
let review_comment_edit_body = serde_json::to_vec(&json!({
"type": "review.comment.edit",
"review": CONTRIBUTOR_PATCH_REVIEW,
"comment": CONTRIBUTOR_COMMENT_3,
"review": review_id,
"comment": comment_id,
"embeds": [],
"body": "EDIT: This is a comment on a review",
}))
@@ -3049,8 +3017,8 @@ mod routes {
let review_react_body = serde_json::to_vec(&json!({
"type": "review.comment.react",
"review": CONTRIBUTOR_PATCH_REVIEW,
"comment": CONTRIBUTOR_COMMENT_3,
"review": review_id,
"comment": comment_id,
"reaction": "🚀",
"active": true
}))
@@ -3065,8 +3033,8 @@ mod routes {
let review_resolve_body = serde_json::to_vec(&json!({
"type": "review.comment.resolve",
"review": CONTRIBUTOR_PATCH_REVIEW,
"comment": CONTRIBUTOR_COMMENT_3,
"review": review_id,
"comment": comment_id,
}))
.unwrap();
patch(
@@ -3118,7 +3086,7 @@ mod routes {
"verdict": "accept",
"summary": "A small review",
"comments": [[
CONTRIBUTOR_COMMENT_3,
comment_id,
{
"author": CONTRIBUTOR_NID,
"location": {
+1
View File
@@ -143,6 +143,7 @@ pub mod logger {
pub fn subscriber() -> impl tracing::Subscriber {
tracing_subscriber::FmtSubscriber::builder()
.with_target(false)
.with_max_level(tracing::Level::DEBUG)
.finish()
}
}
+26 -12
View File
@@ -34,20 +34,16 @@ pub const HEAD: &str = "e8c676b9e3b42308dc9d218b70faa5408f8e58ca";
pub const PARENT: &str = "ee8d6a29304623a78ebfa5eeed5af674d0e58f83";
pub const INITIAL_COMMIT: &str = "f604ce9fd5b7cc77b7609beda45ea8760bee78f7";
pub const DID: &str = "did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi";
pub const ISSUE_ID: &str = "0b0b8ca3b75e109971f87d92c1a6c930e87484c6";
pub const ISSUE_DISCUSSION_ID: &str = "7466975f0bef37b459887824a9655f3e78262522";
pub const ISSUE_COMMENT_ID: &str = "24ee306c508cd731a8427612dbdd826209096f99";
pub const ISSUE_ID: &str = "4f98396a1ac987af59ec069de9b80d9917b27050";
pub const ISSUE_DISCUSSION_ID: &str = "ceafc6629ec8dc0a17644fb5a66726aaafc3ed1c";
pub const ISSUE_COMMENT_ID: &str = "59d35e164a21502bc91ad3391ce49baa32ea6a74";
pub const SESSION_ID: &str = "u9MGAkkfkMOv0uDDB2WeUHBT7HbsO2Dy";
pub const TIMESTAMP: u64 = 1671125284;
pub const CONTRIBUTOR_RID: &str = "rad:z4XaCmN3jLSeiMvW15YTDpNbDHFhG";
pub const CONTRIBUTOR_DID: &str = "did:key:z6Mkk7oqY4pPxhMmGEotDYsFo97vhCj85BLY1H256HrJmjN8";
pub const CONTRIBUTOR_NID: &str = "z6Mkk7oqY4pPxhMmGEotDYsFo97vhCj85BLY1H256HrJmjN8";
pub const CONTRIBUTOR_ISSUE_ID: &str = "7466975f0bef37b459887824a9655f3e78262522";
pub const CONTRIBUTOR_PATCH_ID: &str = "e651ae5869a2c1ac8ad4f6deae4cc835656ffa25";
pub const CONTRIBUTOR_PATCH_REVIEW: &str = "ee3eeba95f4ec418b3d0714e18e0d1ff605dc0e6";
pub const CONTRIBUTOR_COMMENT_1: &str = "d8ff07edbc8d2229e54e70f2f5bc31614287f0dc";
pub const CONTRIBUTOR_COMMENT_2: &str = "f3fca0add53f85bc51a85198efed3273fe13b88e";
pub const CONTRIBUTOR_COMMENT_3: &str = "06990ff59faa12463f693dae7a98eb33d75afd2e";
pub const CONTRIBUTOR_ISSUE_ID: &str = "ceafc6629ec8dc0a17644fb5a66726aaafc3ed1c";
pub const CONTRIBUTOR_PATCH_ID: &str = "4ff2ec53a2d165da7f54705023e847d4f9230bc3";
/// Create a new profile.
pub fn profile(home: &Path, seed: [u8; 32]) -> radicle::Profile {
@@ -96,6 +92,8 @@ fn seed_with_signer<G: Signer>(dir: &Path, profile: radicle::Profile, signer: &G
let routing_db = dir.join("radicle").join("node").join("routing.db");
let addresses_db = dir.join("radicle").join("node").join("addresses.db");
crate::logger::init().ok();
TrackingStore::Config::open(tracking_db).unwrap();
RoutingStore::Table::open(routing_db).unwrap();
AddressStore::Book::open(addresses_db).unwrap();
@@ -191,7 +189,7 @@ fn seed_with_signer<G: Signer>(dir: &Path, profile: radicle::Profile, signer: &G
let storage = &profile.storage;
let repo = storage.repository(id).unwrap();
let mut issues = Issues::open(&repo).unwrap();
let _ = issues
let issue = issues
.create(
"Issue #1".to_string(),
"Change 'hello world' to 'hello everyone'".to_string(),
@@ -201,12 +199,13 @@ fn seed_with_signer<G: Signer>(dir: &Path, profile: radicle::Profile, signer: &G
signer,
)
.unwrap();
tracing::debug!(target: "test", "Contributor issue: {}", issue.id());
// eq. rad patch open
let mut patches = Patches::open(&repo).unwrap();
let oid = radicle::git::Oid::from_str(HEAD).unwrap();
let base = radicle::git::Oid::from_str(PARENT).unwrap();
let _ = patches
let patch = patches
.create(
"A new `hello world`",
"change `hello world` in README to something else",
@@ -217,6 +216,7 @@ fn seed_with_signer<G: Signer>(dir: &Path, profile: radicle::Profile, signer: &G
signer,
)
.unwrap();
tracing::debug!(target: "test", "Contributor patch: {}", patch.id());
let options = crate::Options {
aliases: std::collections::HashMap::new(),
@@ -318,10 +318,24 @@ pub struct Response(axum::response::Response);
impl Response {
pub async fn json(self) -> Value {
let body = hyper::body::to_bytes(self.0.into_body()).await.unwrap();
let body = self.body().await;
serde_json::from_slice(&body).unwrap()
}
pub async fn id(self) -> radicle::git::Oid {
let json = self.json().await;
let string = json["id"].as_str().unwrap();
radicle::git::Oid::from_str(string).unwrap()
}
pub async fn success(self) -> bool {
let json = self.json().await;
let success = json["success"].as_bool();
success.unwrap_or(false)
}
pub fn status(&self) -> axum::http::StatusCode {
self.0.status()
}