Split releases into trusted and untrusted scopes

- Upgrade radicle-artifact to 0.23
- Use radicle-artifact Filters, Trust and Scope for releases
- Count only releases a user can browse: delegate + other
- Count releases only for a single repo, not in repo lists
- Add the scope=trusted|untrusted query
- Keep allAuthors for older clients until a major release
- Replace the All filter with Delegates and Others in the UI
- Open lists and releases on Delegates, also when empty
- Show the Others filter whenever it has content
- Name the scope in the empty artifacts message
- Filter by creator on older nodes, which ignore scope
- Page releases on the raw response length
- Warn on a release by a non-delegate
- Warn on a delegate's release while it shows others' artifacts
- Count only delegate attestations in a badge
- Warn about browser links added by non-delegates
- Say browser downloads are not checked against the CID
- Move the scope and warning logic to `src/lib/releases.ts`
- Test artifact scopes, scope routes, opening on Delegates and the
  warnings
This commit is contained in:
Daniel Norman
2026-10-29 18:21:09 +01:00
parent e36b16592a
commit 1d760abe96
22 changed files with 857 additions and 319 deletions
+9 -8
View File
@@ -1816,9 +1816,9 @@ checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
[[package]]
name = "iroh-base"
version = "1.0.3"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6be73e16ee21c923aca9b3121aaa0db936f7c7ecc156ff47b8dac944c68d59a8"
checksum = "6ae9092be76c9f5429776ab3394f5a5a78dc19dc2524267e884a5b9781546c3f"
dependencies = [
"curve25519-dalek 5.0.0",
"data-encoding",
@@ -2610,9 +2610,9 @@ dependencies = [
[[package]]
name = "radicle-artifact"
version = "0.18.0"
version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8166746c3d6bf1618d97aebb48d8b3239c31527fc96fc235309f7c47543ba23"
checksum = "4cef8738ae3f4298ca4f17c5aa3a0b03b4eae1b2a31440fb8606a42281620530"
dependencies = [
"blake3",
"cid",
@@ -2633,9 +2633,9 @@ dependencies = [
[[package]]
name = "radicle-artifact-client"
version = "0.18.0"
version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59abfdeba1a2476c45264014ccc0e9d86cee55eb327e755506e5bc8c0d2b0153"
checksum = "1ce1435fbdc3a5913094845e4a1572f19b2a3927ff0457ca7756754772d48d9e"
dependencies = [
"cid",
"radicle",
@@ -2647,13 +2647,14 @@ dependencies = [
[[package]]
name = "radicle-artifact-core"
version = "0.18.0"
version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba6ef833ec05bb0731b61854c8f39467896d593845c69c0f9437e6f6f2d7bf36"
checksum = "e8f04cd3826d7da39f6ba773cff7d0475f33cd69bf32937ba707c300660f44aa"
dependencies = [
"blake3",
"cid",
"dunce",
"indexmap",
"iroh-base",
"multibase",
"multihash",
+1 -1
View File
@@ -33,7 +33,7 @@ lru = { version = "0.18.2" }
mime_guess = { version = "2.0.5" }
nonempty = { version = "0.12.0", features = ["serialize"] }
radicle.workspace = true
radicle-artifact = { version = "0.18", optional = true }
radicle-artifact = { version = "0.23", optional = true }
radicle-job = { version = "0.6.0" }
radicle-search = { path = "../radicle-search" }
radicle-surf = { workspace = true, features = ["serde"] }
+33 -8
View File
@@ -15,7 +15,7 @@ use radicle::storage::git::Repository;
use radicle::storage::{ReadRepository, ReadStorage};
use radicle::{git, web, Profile};
#[cfg(feature = "artifacts")]
use radicle_artifact::Releases;
use radicle_artifact::{cache_db_path, Releases};
use tokio::sync::RwLock;
mod error;
@@ -156,8 +156,30 @@ impl Context {
self.search.as_ref()
}
/// Repo metadata for listings. Leaves out the release counts, because
/// they read every release of the repo.
#[allow(clippy::result_large_err)]
pub fn repo_info(&self, repo: &Repository, doc: DocAt) -> Result<repo::Info, error::Error> {
self.build_repo_info(repo, doc, false)
}
/// Repo metadata for a single repo, with the release counts.
#[allow(clippy::result_large_err)]
pub fn repo_info_with_releases(
&self,
repo: &Repository,
doc: DocAt,
) -> Result<repo::Info, error::Error> {
self.build_repo_info(repo, doc, true)
}
#[allow(clippy::result_large_err)]
fn build_repo_info(
&self,
repo: &Repository,
doc: DocAt,
with_releases: bool,
) -> Result<repo::Info, error::Error> {
let DocAt { doc, .. } = doc;
let rid = repo.id();
@@ -185,7 +207,9 @@ impl Context {
"issues": issues,
"patches": patches
});
add_releases_meta(&mut meta, repo);
if with_releases {
add_releases_meta(&mut meta, repo, &self.profile);
}
Some((
id.clone(),
@@ -242,20 +266,21 @@ impl Context {
}
}
/// Add the release count to a project payload's `meta` object.
/// Add the count of releases a user can browse to a project payload's `meta`
/// object: those not left with every artifact redacted by a trusted party.
#[cfg(feature = "artifacts")]
fn add_releases_meta(meta: &mut Value, repo: &Repository) {
let releases = Releases::open(repo)
fn add_releases_meta(meta: &mut Value, repo: &Repository, profile: &Profile) {
let counts = Releases::open_cached(repo, cache_db_path(profile.cobs()))
.ok()
.and_then(|releases| releases.count().ok())
.and_then(|releases| releases.counts().ok())
.unwrap_or_default();
meta["releases"] = json!(releases);
meta["releases"] = json!(counts.delegate + counts.other);
}
/// Without artifact support there is no release store, so the key is omitted.
#[cfg(not(feature = "artifacts"))]
fn add_releases_meta(_meta: &mut Value, _repo: &Repository) {}
fn add_releases_meta(_meta: &mut Value, _repo: &Repository, _profile: &Profile) {}
/// Run a blocking closure on the blocking thread pool.
///
+16 -1
View File
@@ -59,12 +59,27 @@ pub struct CobsQuery<T> {
pub struct ReleasesQuery {
pub page: Option<usize>,
pub per_page: Option<usize>,
/// Include releases and artifacts authored by non-delegates.
/// Whose releases and artifacts to show. Defaults to trusted.
pub scope: Option<ReleaseScope>,
/// Compat: older clients send `allAuthors=true` for every author.
/// Ignored when `scope` is set. Server: remove in a major release, see
/// docs/api-changes.md.
pub all_authors: Option<bool>,
/// Include artifacts redacted by their author or a delegate.
pub show_redacted: Option<bool>,
}
/// The two disjoint author scopes of the releases list. A web node has no
/// local user, so trusted means delegates.
#[cfg(feature = "artifacts")]
#[derive(Default, Serialize, Deserialize, Clone, Copy)]
#[serde(rename_all = "camelCase")]
pub enum ReleaseScope {
#[default]
Trusted,
Untrusted,
}
#[derive(Default, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub enum IssueStatus {
@@ -131,7 +131,6 @@ mod routes {
"open": 1,
"closed": 0,
},
"releases": 0,
}
}
},
@@ -169,7 +168,6 @@ mod routes {
"open": 0,
"closed": 0,
},
"releases": 0,
}
}
},
@@ -229,7 +227,6 @@ mod routes {
"open": 1,
"closed": 0,
},
"releases": 0,
}
}
},
@@ -267,7 +264,6 @@ mod routes {
"open": 0,
"closed": 0,
},
"releases": 0,
}
}
},
@@ -417,7 +417,7 @@ async fn repo_handler(State(ctx): State<Context>, Path(rid): Path<String>) -> im
let rid = ctx.resolve_repo(&rid)?;
let info = api::blocking(move || {
let (repo, doc) = ctx.repo(rid)?;
ctx.repo_info(&repo, doc)
ctx.repo_info_with_releases(&repo, doc)
})
.await?;
@@ -1404,7 +1404,6 @@ mod routes {
"open": 1,
"closed": 0,
},
"releases": 0,
}
}
},
@@ -1442,7 +1441,6 @@ mod routes {
"open": 0,
"closed": 0,
},
"releases": 0,
}
}
},
@@ -1493,7 +1491,6 @@ mod routes {
"open": 1,
"closed": 0,
},
"releases": 0,
}
}
},
@@ -1531,7 +1528,6 @@ mod routes {
"open": 0,
"closed": 0,
},
"releases": 0,
}
}
},
@@ -1,76 +1,39 @@
use std::collections::BTreeSet;
use axum::extract::State;
use axum::response::IntoResponse;
use axum::Json;
use serde_json::{json, Value};
use radicle::git::Oid;
use radicle::identity::doc::Delegates;
use radicle::identity::Did;
use radicle::node::AliasStore;
use radicle::storage::git::Repository;
use radicle_artifact::display::{CommitTitle, TagName};
use radicle_artifact::{cache_db_path, Artifact, Cid, Release, ReleaseId, Releases};
use radicle_artifact::trust::{Scope, Trust};
use radicle_artifact::{cache_db_path, Artifact, Cid, Filters, Release, ReleaseId, Releases};
use crate::api;
use crate::api::error::Error;
use crate::api::json::Author;
use crate::api::query::{ReleasesQuery, MAX_PER_PAGE};
use crate::api::query::{ReleaseScope, ReleasesQuery, MAX_PER_PAGE};
use crate::api::Context;
use crate::axum_extra::{Path, Query};
/// Default number of releases returned per page.
const DEFAULT_PER_PAGE: usize = 30;
/// Whether an artifact was redacted by its own author or by a delegate.
fn redacted_by_trusted(artifact: &Artifact, delegates: &Delegates) -> bool {
artifact
.redactions()
.keys()
.any(|did| did == artifact.author() || delegates.contains(did))
}
/// How far the caller widened the default, delegate-scoped release view.
#[derive(Clone, Copy)]
struct Filter {
/// Include releases and artifacts authored by non-delegates.
all_authors: bool,
/// Include artifacts redacted by their author or a delegate.
show_redacted: bool,
}
impl Filter {
/// Whether an artifact is shown under this view: authored by a delegate
/// (or `all_authors`), and not redacted by its author or a delegate (or
/// `show_redacted`).
fn show_artifact(&self, artifact: &Artifact, delegates: &Delegates) -> bool {
(self.all_authors || delegates.contains(artifact.author()))
&& (self.show_redacted || !redacted_by_trusted(artifact, delegates))
}
/// Whether a release is shown under this view: created by a delegate (or
/// `all_authors`), and not left with all of its artifacts redacted by a
/// trusted party (or `show_redacted`).
fn show_release(&self, release: &Release, delegates: &Delegates) -> bool {
(self.all_authors || delegates.contains(release.creator()))
&& (self.show_redacted || !release_redacted(release, delegates))
}
}
/// Whether every artifact of a release was redacted by a trusted party. A
/// release without artifacts is not redacted; it has nothing to redact.
fn release_redacted(release: &Release, delegates: &Delegates) -> bool {
let artifacts = release.artifacts();
!artifacts.is_empty()
&& artifacts
.values()
.all(|artifact| redacted_by_trusted(artifact, delegates))
}
/// Serialize a single artifact. Locations are flattened across contributors
/// into `{ user, url }` entries; attestations are the attesting nodes;
/// redactions carry the flagging user and its reason.
fn artifact_json(cid: &Cid, artifact: &Artifact, aliases: &impl AliasStore) -> Value {
/// redactions carry the flagging user and its reason; metadata is only what
/// its author or a delegate wrote.
fn artifact_json(
cid: &Cid,
artifact: &Artifact,
delegates: &BTreeSet<Did>,
aliases: &impl AliasStore,
) -> Value {
let locations = artifact
.locations()
.iter()
@@ -97,12 +60,12 @@ fn artifact_json(cid: &Cid, artifact: &Artifact, aliases: &impl AliasStore) -> V
"locations": locations,
"attestations": attestations,
"redactions": redactions,
"metadata": artifact.metadata(),
"metadata": artifact.trusted_metadata(delegates),
})
}
/// Serialize a release. The COB has no title field: it is resolved from the
/// tag message when a tag is linked, otherwise the commit summary. `filter`
/// tag message when a tag is linked, otherwise the commit summary. `filters`
/// omits the artifacts hidden under the current view; `None` keeps all of
/// them, as a release fetched by id does.
fn release_json(
@@ -110,8 +73,8 @@ fn release_json(
release: &Release,
repo: &Repository,
aliases: &impl AliasStore,
delegates: &Delegates,
filter: Option<Filter>,
delegates: &BTreeSet<Did>,
filters: Option<Filters>,
) -> Value {
let title = release
.tag()
@@ -122,10 +85,8 @@ fn release_json(
let artifacts = release
.artifacts()
.iter()
.filter(|(_, artifact)| {
filter.is_none_or(|filter| filter.show_artifact(artifact, delegates))
})
.map(|(cid, artifact)| artifact_json(cid, artifact, aliases))
.filter(|(_, artifact)| filters.is_none_or(|f| f.shows_artifact(artifact)))
.map(|(cid, artifact)| artifact_json(cid, artifact, delegates, aliases))
.collect::<Vec<_>>();
json!({
@@ -140,13 +101,27 @@ fn release_json(
})
}
/// The filters a listed release shows its artifacts under. A release page opens
/// on the delegates' artifacts, so a teaser counts those in either scope.
fn listed_filters(filters: Filters<'_>) -> Filters<'_> {
match filters.scope {
Scope::All => filters,
Scope::Trusted | Scope::Untrusted => Filters {
scope: Scope::Trusted,
..filters
},
}
}
/// Get repo releases list, newest first.
/// `GET /repos/:rid/releases`
///
/// Scoped to releases created by a delegate and artifacts authored by a
/// delegate (hiding those redacted by a trusted party) unless widened with
/// `allAuthors=true` / `showRedacted=true`. A release whose artifacts were all
/// redacted is hidden with them.
/// Scoped to releases created by a delegate, or with `scope=untrusted` to
/// those by non-delegates, so the two scopes never overlap. Each release lists
/// only the artifacts authored by a delegate, in either scope. Artifacts
/// redacted by a trusted party are hidden unless `showRedacted=true`. A release
/// whose artifacts were all redacted is hidden with them; a release with no
/// artifacts is shown.
pub async fn list_handler(
State(ctx): State<Context>,
Path(rid): Path<String>,
@@ -154,54 +129,52 @@ pub async fn list_handler(
) -> impl IntoResponse {
let rid = ctx.resolve_repo(&rid)?;
let releases = api::blocking(move || {
let (repo, doc) = ctx.repo(rid)?;
let delegates = doc.delegates();
let (repo, _) = ctx.repo(rid)?;
let aliases = ctx.profile.aliases();
let ReleasesQuery {
page,
per_page,
scope,
all_authors,
show_redacted,
} = qs;
let page = page.unwrap_or(0);
let per_page = per_page.unwrap_or(DEFAULT_PER_PAGE).min(MAX_PER_PAGE);
let filter = Filter {
all_authors: all_authors.unwrap_or(false),
show_redacted: show_redacted.unwrap_or(false),
};
// Read through the SQLite cache; it self-warms on read and is shared
// with other release reads on this node.
let cache = cache_db_path(ctx.profile.cobs());
let mut releases: Vec<_> = Releases::open_cached(&repo, cache)?
.all()?
.into_iter()
.filter_map(|r| {
let (id, release) = r.ok()?;
filter
.show_release(&release, delegates)
.then_some((id, release))
// with other release reads on this node. `list` is sorted newest
// first and lazy, so only the rows up to the requested page are read.
let store = Releases::open_cached(&repo, cache_db_path(ctx.profile.cobs()))?;
// A web node has no local user, so only delegates are trusted unless
// widened.
let filters = Filters {
trust: Trust::new(store.delegates(), None),
scope: match (scope, all_authors) {
(Some(ReleaseScope::Untrusted), _) => Scope::Untrusted,
(None, Some(true)) => Scope::All,
_ => Scope::Trusted,
},
include_redacted: show_redacted.unwrap_or(false),
};
let releases = store
.list()?
.filter_map(Result::ok)
.filter(|(_, release)| filters.shows_release(release))
.skip(page * per_page)
.take(per_page)
.map(|(id, release)| {
release_json(
ReleaseId::from(id),
&release,
&repo,
&aliases,
store.delegates(),
Some(listed_filters(filters)),
)
})
.collect();
releases.sort_by_key(|(_, release)| std::cmp::Reverse(release.timestamp()));
.collect::<Vec<_>>();
Ok::<_, Error>(
releases
.into_iter()
.skip(page * per_page)
.take(per_page)
.map(|(id, release)| {
release_json(
ReleaseId::from(id),
&release,
&repo,
&aliases,
delegates,
Some(filter),
)
})
.collect::<Vec<_>>(),
)
Ok::<_, Error>(releases)
})
.await?;
@@ -216,11 +189,11 @@ pub async fn get_handler(
) -> impl IntoResponse {
let rid = ctx.resolve_repo(&rid)?;
let value = api::blocking(move || {
let (repo, doc) = ctx.repo(rid)?;
let delegates = doc.delegates();
let (repo, _) = ctx.repo(rid)?;
let aliases = ctx.profile.aliases();
let cache = cache_db_path(ctx.profile.cobs());
let release = Releases::open_cached(&repo, cache)?
let store = Releases::open_cached(&repo, cache)?;
let release = store
.get(&ReleaseId::from(release_id))?
.ok_or(Error::NotFound)?;
@@ -229,7 +202,7 @@ pub async fn get_handler(
&release,
&repo,
&aliases,
delegates,
store.delegates(),
None,
))
})
@@ -258,7 +231,7 @@ mod routes {
use url::Url;
use crate::api::Context;
use crate::test::{get, seed, DID, HEAD, RID};
use crate::test::{get, seed, DID, HEAD, PARENT, RID};
/// A valid CIDv1 string; the release COB stores it verbatim.
const CID: &str = "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi";
@@ -379,20 +352,35 @@ mod routes {
}
#[tokio::test]
async fn test_repos_releases_hides_non_delegate_creator() {
async fn test_repos_releases_scopes_are_disjoint() {
let tmp = tempfile::tempdir().unwrap();
let ctx = seed(tmp.path());
create_release(&ctx, NON_DELEGATE_SEED);
let delegate_id = create_release(&ctx, DELEGATE_SEED);
let other_id = create_release(&ctx, NON_DELEGATE_SEED);
let app = app(ctx);
let ids = |body: serde_json::Value| {
body.as_array()
.unwrap()
.iter()
.map(|release| release["id"].as_str().unwrap().to_owned())
.collect::<Vec<_>>()
};
// Default view is scoped to delegate creators.
let response = get(&app, format!("/repos/{RID}/releases")).await;
assert_eq!(response.json().await, json!([]));
assert_eq!(ids(response.json().await), [delegate_id.as_str()]);
// `allAuthors=true` widens it to include non-delegate creators.
// `scope=untrusted` shows only non-delegate creators.
let response = get(&app, format!("/repos/{RID}/releases?scope=untrusted")).await;
assert_eq!(ids(response.json().await), [other_id.as_str()]);
// Older clients send `allAuthors=true` for every creator.
let response = get(&app, format!("/repos/{RID}/releases?allAuthors=true")).await;
let body = response.json().await;
assert_eq!(body.as_array().unwrap().len(), 1);
let mut all = ids(response.json().await);
all.sort();
let mut expected = vec![delegate_id.as_str(), other_id.as_str()];
expected.sort();
assert_eq!(all, expected);
}
#[tokio::test]
@@ -431,11 +419,59 @@ mod routes {
assert_eq!(artifacts.len(), 1);
assert_eq!(artifacts[0]["cid"], json!(CID));
// `allAuthors=true` widens it to include the non-delegate's artifact.
let response = get(&app, format!("/repos/{RID}/releases?allAuthors=true")).await;
// A delegate created it, so it is not among the others' releases.
let response = get(&app, format!("/repos/{RID}/releases?scope=untrusted")).await;
assert_eq!(response.json().await, json!([]));
}
#[tokio::test]
async fn test_repos_releases_list_only_delegate_artifacts() {
let tmp = tempfile::tempdir().unwrap();
let ctx = seed(tmp.path());
// A delegate release with only a non-delegate artifact, and a
// non-delegate release with an artifact by each.
{
let signer = SigningKey::from_seed(Seed::new(DELEGATE_SEED));
let other_signer = SigningKey::from_seed(Seed::new(NON_DELEGATE_SEED));
let rid = RepoId::from_str(RID).unwrap();
let repo = ctx.profile().storage.repository_mut(rid).unwrap();
let mut releases = Releases::open(&repo).unwrap();
let cid = Cid::from_str(CID).unwrap();
let other_cid = Cid::from_str(CID_2).unwrap();
let mut release = releases
.create(Oid::from_str(HEAD).unwrap(), None, &signer)
.unwrap();
release
.register_artifact(other_cid, "eve-build".to_string(), &other_signer)
.unwrap();
let mut release = releases
.create(Oid::from_str(PARENT).unwrap(), None, &other_signer)
.unwrap();
release
.register_artifact(cid, "linux-amd64".to_string(), &signer)
.unwrap();
release
.register_artifact(other_cid, "eve-build".to_string(), &other_signer)
.unwrap();
}
let app = app(ctx);
// The scope never widens to the non-delegate's artifact.
let response = get(&app, format!("/repos/{RID}/releases")).await;
let body = response.json().await;
assert_eq!(body[0]["oid"], json!(HEAD));
assert_eq!(body[0]["artifacts"], json!([]));
// Among the others' releases too, only the delegate's artifact counts.
let response = get(&app, format!("/repos/{RID}/releases?scope=untrusted")).await;
let body = response.json().await;
assert_eq!(body[0]["oid"], json!(PARENT));
let artifacts = body[0]["artifacts"].as_array().unwrap();
assert_eq!(artifacts.len(), 2);
assert_eq!(artifacts.len(), 1);
assert_eq!(artifacts[0]["cid"], json!(CID));
}
#[tokio::test]
@@ -476,6 +512,29 @@ mod routes {
);
}
#[tokio::test]
async fn test_repos_releases_shows_empty_release() {
let tmp = tempfile::tempdir().unwrap();
let ctx = seed(tmp.path());
{
let signer = SigningKey::from_seed(Seed::new(DELEGATE_SEED));
let rid = RepoId::from_str(RID).unwrap();
let repo = ctx.profile().storage.repository_mut(rid).unwrap();
let mut releases = Releases::open(&repo).unwrap();
releases
.create(Oid::from_str(HEAD).unwrap(), None, &signer)
.unwrap();
}
let app = app(ctx);
// A release with no artifacts has nothing redacted, so the default
// view shows it.
let response = get(&app, format!("/repos/{RID}/releases")).await;
let body = response.json().await;
assert_eq!(body.as_array().unwrap().len(), 1);
assert_eq!(body[0]["artifacts"], json!([]));
}
#[tokio::test]
async fn test_repos_releases_alias_resolution() {
let tmp = tempfile::tempdir().unwrap();
+4
View File
@@ -4,6 +4,8 @@ import type {
DiffResponse,
Job,
PeerRefs,
ReleaseScope,
ReleaseCounts,
Remote,
Repo,
RepoListQuery,
@@ -96,6 +98,8 @@ export type {
Reaction,
Redaction,
Release,
ReleaseScope,
ReleaseCounts,
Remote,
Repo,
RepoListQuery,
+22 -2
View File
@@ -51,6 +51,22 @@ const tagInfoSchema = object({
export type TagInfo = z.infer<typeof tagInfoSchema>;
// Releases bucketed by whether a delegate created them and whether a trusted
// party redacted every artifact they carry. A release with no artifacts is not
// redacted.
const releaseCountsSchema = object({
delegate: number(),
delegateRedacted: number(),
other: number(),
otherRedacted: number(),
});
export type ReleaseCounts = z.infer<typeof releaseCountsSchema>;
// The two disjoint author scopes of the releases list: releases created by a
// delegate, or by anyone else.
export type ReleaseScope = "trusted" | "untrusted";
const repoSchema = object({
rid: string(),
payloads: object({
@@ -72,8 +88,9 @@ const repoSchema = object({
open: number(),
closed: number(),
}),
// Optional: absent on older nodes that don't report release counts.
releases: optional(number()),
// Absent on older nodes that don't report release counts, and a
// single count on nodes that report one instead of the buckets.
releases: optional(union([releaseCountsSchema, number()])),
}),
}),
}),
@@ -600,6 +617,9 @@ export class Client {
query?: {
page?: number;
perPage?: number;
scope?: ReleaseScope;
// Older nodes ignore `scope` and only know this superset of both
// scopes.
allAuthors?: boolean;
showRedacted?: boolean;
},
+74
View File
@@ -0,0 +1,74 @@
import type { Artifact, Author, ReleaseScope } from "@http-client";
export interface ArtifactView {
shown: Artifact[];
redactedCount: number;
counts: Record<ReleaseScope, number>;
showFilters: boolean;
}
// Whether the artifact was redacted by a trusted party (its author or a
// delegate), mirroring the backend's default-hidden rule.
export function redactedByTrusted(
artifact: Artifact,
delegates: Set<string>,
): boolean {
return artifact.redactions.some(
r => r.user.id === artifact.author.id || delegates.has(r.user.id),
);
}
// The artifacts of a release in one author scope. The endpoint returns every
// author, so the split happens here. The scope asked for is shown even when it
// is empty: switching to others by itself would show untrusted content unasked.
export function artifactView(
artifacts: Artifact[],
delegates: Set<string>,
scope: ReleaseScope,
showRedacted: boolean,
): ArtifactView {
const byScope: Record<ReleaseScope, Artifact[]> = {
trusted: artifacts.filter(a => delegates.has(a.author.id)),
untrusted: artifacts.filter(a => !delegates.has(a.author.id)),
};
const visible = (list: Artifact[]) =>
showRedacted ? list : list.filter(a => !redactedByTrusted(a, delegates));
return {
shown: visible(byScope[scope]),
// The hidden set within the current author scope.
redactedCount: byScope[scope].filter(a => redactedByTrusted(a, delegates))
.length,
// Counts reflect what each scope would show, so an artifact hidden as
// redacted never counts. The filter itself comes from the unfiltered sets,
// so toggling redacted artifacts never hides it.
counts: {
trusted: visible(byScope.trusted).length,
untrusted: visible(byScope.untrusted).length,
},
// Others is one click away whenever it holds something.
showFilters: byScope.untrusted.length > 0,
};
}
// Anyone can attest their own artifact, so only delegates count.
export function attestedBy(
attestations: Author[],
delegates: Set<string>,
): number {
return attestations.filter(a => delegates.has(a.id)).length;
}
// A non-delegate's release is untrusted as a whole, as its creator chose its
// title, tag and commit. A delegate's release is untrusted only while it
// shows artifacts by non-delegates.
export function releaseWarning(
creator: string,
delegates: Set<string>,
artifactScope: ReleaseScope,
): "release" | "artifacts" | undefined {
if (!delegates.has(creator)) {
return "release";
}
return artifactScope === "untrusted" ? "artifacts" : undefined;
}
+14
View File
@@ -13,6 +13,20 @@ export function defaultBranch(repo: Repo): string {
return repo.payloads["xyz.radicle.project"].data.defaultBranch;
}
// Counts the releases a user can browse: the two unredacted buckets, or the
// single count older nodes report in place of the buckets. Undefined means the
// node doesn't report releases at all, which is how the release API is
// detected.
export function visibleReleaseCount(repo: Repo): number | undefined {
const releases = repo.payloads["xyz.radicle.project"].meta.releases;
if (releases === undefined) {
return undefined;
}
return typeof releases === "number"
? releases
: releases.delegate + releases.other;
}
export function unqualifyBranch(refname: string): string {
return refname.startsWith(REFS_HEADS)
? refname.slice(REFS_HEADS.length)
+7 -4
View File
@@ -9,7 +9,7 @@
import config from "@app/lib/config";
import debounce from "lodash/debounce";
import { routeToPath } from "@app/lib/router";
import { toClipboard } from "@app/lib/utils";
import { toClipboard, visibleReleaseCount } from "@app/lib/utils";
import Button from "@app/components/Button.svelte";
import Icon from "@app/components/Icon.svelte";
@@ -21,6 +21,9 @@
export let repo: Repo;
export let repoId: string;
// Undefined on older nodes that don't report release counts.
$: releaseCount = visibleReleaseCount(repo);
let shareIcon: "link" | "checkmark" = "link";
const restoreIcon = debounce(() => {
@@ -160,9 +163,9 @@
</Button>
</Link>
<!-- Only nodes that report a release count support the release API; hide the
<!-- Only nodes that report release counts support the release API; hide the
tab on older nodes where the field is absent. -->
{#if repo.payloads["xyz.radicle.project"].meta.releases !== undefined}
{#if releaseCount !== undefined}
<Link
route={{
resource: "repo.releases",
@@ -179,7 +182,7 @@
class="counter"
class:hover={hover && activeTab !== "releases"}
class:selected={activeTab === "releases"}>
{repo.payloads["xyz.radicle.project"].meta.releases}
{releaseCount}
</span>
</div>
</Button>
+73 -59
View File
@@ -1,7 +1,19 @@
<script lang="ts">
import type { Artifact, BaseUrl, Release, Repo } from "@http-client";
import type {
Artifact,
BaseUrl,
Release,
ReleaseScope,
Repo,
} from "@http-client";
import * as utils from "@app/lib/utils";
import {
artifactView,
attestedBy,
redactedByTrusted,
releaseWarning,
} from "@app/lib/releases";
import Badge from "@app/components/Badge.svelte";
import Button from "@app/components/Button.svelte";
@@ -15,12 +27,13 @@
import Link from "@app/components/Link.svelte";
import NodeId from "@app/components/NodeId.svelte";
import Separator from "./Separator.svelte";
import UntrustedWarning from "./Release/UntrustedWarning.svelte";
export let baseUrl: BaseUrl;
export let release: Release;
export let repo: Repo;
export let repoId: string;
export let allAuthors: boolean;
export let scope: ReleaseScope | undefined = undefined;
export let nodeId: string;
export let nodeAvatarUrl: string | undefined;
@@ -33,14 +46,6 @@
$: delegateIds = new Set(repo.delegates.map(d => d.id));
// Whether the artifact was redacted by a trusted party (its author or a
// delegate), mirroring the backend's default-hidden rule.
function redactedByTrusted(artifact: Artifact, delegates: Set<string>) {
return artifact.redactions.some(
r => r.user.id === artifact.author.id || delegates.has(r.user.id),
);
}
// Label for the redacted badge. Delegate takes precedence over author,
// since the author may also be a delegate.
function redactedByLabel(artifact: Artifact, delegates: Set<string>): string {
@@ -48,46 +53,26 @@
return byDelegate ? "Redacted by delegate" : "Redacted by author";
}
// Artifacts visible under the given redaction toggle.
function visible(
list: Artifact[],
show: boolean,
delegates: Set<string>,
): Artifact[] {
return show ? list : list.filter(a => !redactedByTrusted(a, delegates));
}
$: delegateArtifacts = release.artifacts.filter(a =>
delegateIds.has(a.author.id),
);
// Artifacts are scoped to delegate authors by default; the endpoint returns
// all of them, so filtering happens client-side. With no delegate artifact
// at all, fall back to every author.
$: authorArtifacts =
allAuthors || delegateArtifacts.length === 0
? release.artifacts
: delegateArtifacts;
$: shownArtifacts = visible(authorArtifacts, showRedacted, delegateIds);
// The redacted count is the hidden set within the current author scope.
$: redactedCount = authorArtifacts.filter(a =>
redactedByTrusted(a, delegateIds),
).length;
// Segment counts reflect what each choice would actually show, so an
// artifact hidden as redacted never counts towards a scope. They label the
// segments only: the author scope and the filter visibility come from the
// unfiltered sets, so toggling redacted artifacts never moves them.
$: delegateCount = visible(
delegateArtifacts,
showRedacted,
$: activeScope = scope ?? "trusted";
$: view = artifactView(
release.artifacts,
delegateIds,
).length;
$: allCount = visible(release.artifacts, showRedacted, delegateIds).length;
activeScope,
showRedacted,
);
$: shownArtifacts = view.shown;
$: redactedCount = view.redactedCount;
$: delegateCount = view.counts.trusted;
$: otherCount = view.counts.untrusted;
$: showFilters = view.showFilters;
// Filter only when both scopes hold something.
$: showFilters =
delegateArtifacts.length > 0 &&
delegateArtifacts.length !== release.artifacts.length;
$: warning = releaseWarning(release.creator.id, delegateIds, activeScope);
$: untrustedWarning =
warning === "release"
? "Not from a delegate. Only download if you trust the author."
: warning === "artifacts"
? "Not from delegates. Only download if you trust the authors."
: undefined;
// Format a byte count as a human-readable size (mirrors the CLI display).
function formatBytes(bytes: number): string {
@@ -186,6 +171,10 @@
.redacted-toggle {
margin-left: auto;
}
.untrusted-warning {
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--color-border-subtle);
}
.filter-divider {
border-bottom: 1px solid var(--color-border-subtle);
}
@@ -382,7 +371,7 @@
resource: "repo.releases",
repo: repoId,
node: baseUrl,
allAuthors,
scope: delegateIds.has(release.creator.id) ? undefined : "untrusted",
}}>
Releases
</Link>
@@ -436,6 +425,11 @@
</CobHeader>
<div class="artifacts">
{#if untrustedWarning}
<div class="untrusted-warning">
<UntrustedWarning text={untrustedWarning} />
</div>
{/if}
{#if showFilters || redactedCount > 0}
<div class="filter">
{#if showFilters}
@@ -449,14 +443,14 @@
}}>
<Button
let:hover
variant={!allAuthors ? "gray" : "background"}>
variant={activeScope === "trusted" ? "gray" : "background"}>
<Icon name="badge" />
<div class="title-counter">
Delegates
<span
class="counter"
class:selected={!allAuthors}
class:hover={hover && allAuthors}>
class:selected={activeScope === "trusted"}
class:hover={hover && activeScope !== "trusted"}>
{delegateCount}
</span>
</div>
@@ -468,19 +462,22 @@
repo: repoId,
node: baseUrl,
release: release.id,
allAuthors: true,
scope: "untrusted",
}}>
<Button
let:hover
variant={allAuthors ? "gray" : "background"}>
title="Non-delegates"
variant={activeScope === "untrusted"
? "gray"
: "background"}>
<Icon name="avatar-incognito" />
<div class="title-counter">
All
Others
<span
class="counter"
class:selected={allAuthors}
class:hover={hover && !allAuthors}>
{allCount}
class:selected={activeScope === "untrusted"}
class:hover={hover && activeScope !== "untrusted"}>
{otherCount}
</span>
</div>
</Button>
@@ -505,7 +502,11 @@
{#if shownArtifacts.length === 0}
<div class="empty-artifacts">
<Icon name="attach" />
No artifacts
{!showFilters
? "No artifacts"
: activeScope === "trusted"
? "No artifacts by delegates"
: "No artifacts by others"}
</div>
{/if}
@@ -520,6 +521,10 @@
{@const locationCount = artifact.locations.length}
{@const cliOnly =
locationCount > 0 && !artifact.locations.some(l => isWebUrl(l.url))}
{@const attestedByDelegates = attestedBy(
artifact.attestations,
delegateIds,
)}
{@const redactions = delegatesFirst(
artifact.redactions,
r => r.user.id,
@@ -534,6 +539,15 @@
{redactedByLabel(artifact, delegateIds)}
</Badge>
{/if}
{#if attestedByDelegates > 0}
<Badge size="tiny" variant="positive">
<Icon name="checkmark" />
Attested by {attestedByDelegates} delegate{attestedByDelegates ===
1
? ""
: "s"}
</Badge>
{/if}
<div class="artifact-actions">
{#if size}
<span class="size">{size}</span>
@@ -9,6 +9,7 @@
import Popover from "@app/components/Popover.svelte";
import Radio from "@app/components/Radio.svelte";
import ExternalLink from "@app/components/ExternalLink.svelte";
import UntrustedWarning from "./UntrustedWarning.svelte";
export let artifact: Artifact;
export let rid: string;
@@ -25,6 +26,8 @@
(a, b) =>
Number(delegateIds.has(b.user.id)) - Number(delegateIds.has(a.user.id)),
);
$: authorTrusted = delegateIds.has(artifact.author.id);
$: linksByOthers = webLocations.some(l => !delegateIds.has(l.user.id));
// Any location at all can be fetched with the CLI, web locations included.
$: downloadable = artifact.locations.length > 0;
$: command = `rad artifact -r ${rid} download --cid ${artifact.cid}`;
@@ -50,6 +53,9 @@
.warning :global(svg) {
flex-shrink: 0;
}
.untrusted {
margin-bottom: 0.75rem;
}
.locations {
display: flex;
flex-direction: column;
@@ -90,6 +96,12 @@
</Button>
<div slot="popover" style:width="24rem" class="popover">
{#if !authorTrusted}
<div class="untrusted">
<UntrustedWarning
text="Not from a delegate. Only download if you trust the author." />
</div>
{/if}
<div style:margin-bottom="1.5rem">
<Radio ariaLabel="Toggle download method" styleGap="2px">
<Button
@@ -131,8 +143,14 @@
{:else}
<div class="warning">
<Icon name="warning" />
These downloads are not verified.
These downloads are not checked against the CID.
</div>
{#if authorTrusted && linksByOthers}
<div class="untrusted">
<UntrustedWarning
text="Links without a delegate badge are not from a delegate. Only use them if you trust who added them." />
</div>
{/if}
<div class="locations">
{#each webLocations as location (location.url)}
<div class="location">
@@ -14,7 +14,6 @@
export let delegateIds: Set<string>;
export let release: Release;
export let repoId: string;
export let allAuthors: boolean = false;
// The COB has no title; fall back to the tag name, then the release id.
$: title = release.title || release.tagName || release.id;
@@ -84,7 +83,6 @@
repo: repoId,
node: baseUrl,
release: release.id,
allAuthors,
}}>
<InlineTitle fontSize="body-m-regular" content={title} />
</Link>
@@ -0,0 +1,25 @@
<script lang="ts">
import Icon from "@app/components/Icon.svelte";
export let text: string;
</script>
<style>
.banner {
display: flex;
align-items: center;
gap: 0.375rem;
color: var(--color-text-secondary);
font: var(--txt-body-s-regular);
}
.icon {
display: flex;
flex-shrink: 0;
color: var(--color-feedback-warning-text);
}
</style>
<div class="banner" role="note">
<span class="icon"><Icon name="warning" /></span>
<span>{text}</span>
</div>
+55 -38
View File
@@ -1,8 +1,8 @@
<script lang="ts">
import type { BaseUrl, Release, Repo } from "@http-client";
import type { BaseUrl, Release, ReleaseScope, Repo } from "@http-client";
import { HttpdClient } from "@http-client";
import { RELEASES_PER_PAGE } from "./router";
import { RELEASES_PER_PAGE, releasesQuery, scopeReleases } from "./router";
import { baseUrlToString } from "@app/lib/utils";
import Button from "@app/components/Button.svelte";
@@ -15,12 +15,14 @@
import Placeholder from "@app/components/Placeholder.svelte";
import ReleaseTeaser from "@app/views/repos/Release/ReleaseTeaser.svelte";
import Separator from "./Separator.svelte";
import UntrustedWarning from "@app/views/repos/Release/UntrustedWarning.svelte";
export let baseUrl: BaseUrl;
export let releases: Release[];
export let lastPageFull: boolean;
export let repo: Repo;
export let repoId: string;
export let allAuthors: boolean;
export let scope: ReleaseScope;
export let showFilters: boolean;
export let nodeId: string;
export let nodeAvatarUrl: string | undefined;
@@ -30,21 +32,19 @@
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let error: any;
let allReleases: Release[];
let morePages: boolean;
$: {
allReleases = releases;
morePages = lastPageFull;
page = 0;
}
$: delegateIds = new Set(repo.delegates.map(d => d.id));
// The delegate count isn't in repo metadata; derive it from the loaded
// pages. It labels the segment only, and reads as "30+" while pages are
// outstanding; whether the segments show at all is settled by the router.
$: delegateReleaseCount = allAuthors
? allReleases.filter(r => delegateIds.has(r.creator.id)).length
: allReleases.length;
$: releaseCount = repo.payloads["xyz.radicle.project"].meta.releases;
// Older nodes report a single count, so their segments go without counters.
$: releasesMeta = repo.payloads["xyz.radicle.project"].meta.releases;
$: counts = typeof releasesMeta === "object" ? releasesMeta : undefined;
const api = new HttpdClient(baseUrl);
@@ -54,12 +54,12 @@
// doesn't leave a gap behind on retry.
const next = page + 1;
try {
const response = await api.repo.getAllReleases(repo.rid, {
allAuthors,
page: next,
perPage: RELEASES_PER_PAGE,
});
allReleases = [...allReleases, ...response];
const response = await api.repo.getAllReleases(
repo.rid,
releasesQuery(scope, next),
);
allReleases = [...allReleases, ...scopeReleases(response, repo, scope)];
morePages = response.length === RELEASES_PER_PAGE;
page = next;
} catch (e) {
error = e;
@@ -69,10 +69,9 @@
}
// The count for the current filter isn't in repo metadata, so offer "More"
// whenever every loaded page came back full; a short page means we've reached
// the end.
$: showMoreButton =
!loading && !error && allReleases.length === (page + 1) * RELEASES_PER_PAGE;
// while the last page came back full; a short page means we've reached the
// end.
$: showMoreButton = !loading && !error && morePages;
</script>
<style>
@@ -83,6 +82,10 @@
padding: 1rem;
border-bottom: 1px solid var(--color-border-subtle);
}
.warning {
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--color-border-subtle);
}
.counter {
border-radius: var(--border-radius-sm);
background-color: var(--color-surface-mid);
@@ -141,16 +144,20 @@
<div class="header">
<Link
route={{ resource: "repo.releases", repo: repoId, node: baseUrl }}>
<Button let:hover variant={!allAuthors ? "gray" : "background"}>
<Button
let:hover
variant={scope === "trusted" ? "gray" : "background"}>
<Icon name="badge" />
<div class="title-counter">
Delegates
<span
class="counter"
class:selected={!allAuthors}
class:hover={hover && allAuthors}>
{delegateReleaseCount}{showMoreButton ? "+" : ""}
</span>
{#if counts}
<span
class="counter"
class:selected={scope === "trusted"}
class:hover={hover && scope !== "trusted"}>
{counts.delegate}
</span>
{/if}
</div>
</Button>
</Link>
@@ -159,18 +166,21 @@
resource: "repo.releases",
repo: repoId,
node: baseUrl,
allAuthors: true,
scope: "untrusted",
}}>
<Button let:hover variant={allAuthors ? "gray" : "background"}>
<Button
let:hover
title="Non-delegates"
variant={scope === "untrusted" ? "gray" : "background"}>
<Icon name="avatar-incognito" />
<div class="title-counter">
All
{#if releaseCount !== undefined}
Others
{#if counts}
<span
class="counter"
class:selected={allAuthors}
class:hover={hover && !allAuthors}>
{releaseCount}
class:selected={scope === "untrusted"}
class:hover={hover && scope !== "untrusted"}>
{counts.other}
</span>
{/if}
</div>
@@ -178,6 +188,12 @@
</Link>
</div>
{/if}
{#if scope === "untrusted"}
<div class="warning">
<UntrustedWarning
text="Not from delegates. Only download from authors you trust." />
</div>
{/if}
</svelte:fragment>
<List items={allReleases}>
@@ -186,7 +202,6 @@
let:item
{baseUrl}
{repoId}
{allAuthors}
{delegateIds}
release={item} />
</List>
@@ -203,9 +218,11 @@
<div class="placeholder">
<Placeholder
iconName="parcel"
caption={showFilters && !allAuthors
? "No releases by delegates"
: "No releases"} />
caption={!showFilters
? "No releases"
: scope === "trusted"
? "No releases by delegates"
: "No releases by others"} />
</div>
{/if}
+84 -66
View File
@@ -18,6 +18,7 @@ import type {
PatchState,
PeerRefs,
Release,
ReleaseScope,
Remote,
Repo,
Revision,
@@ -111,7 +112,7 @@ interface RepoReleasesRoute {
resource: "repo.releases";
node: BaseUrl;
repo: string;
allAuthors?: boolean;
scope?: ReleaseScope;
}
interface RepoReleaseRoute {
@@ -119,7 +120,7 @@ interface RepoReleaseRoute {
node: BaseUrl;
repo: string;
release: string;
allAuthors?: boolean;
scope?: ReleaseScope;
}
interface RepoIssueRoute {
@@ -284,7 +285,8 @@ export type RepoLoadedRoute =
repo: Repo;
repoId: string;
releases: Release[];
allAuthors: boolean;
lastPageFull: boolean;
scope: ReleaseScope;
showFilters: boolean;
nodeId: string;
nodeAvatarUrl: string | undefined;
@@ -297,7 +299,7 @@ export type RepoLoadedRoute =
repo: Repo;
repoId: string;
release: Release;
allAuthors: boolean;
scope?: ReleaseScope;
nodeId: string;
nodeAvatarUrl: string | undefined;
};
@@ -504,81 +506,77 @@ function releasesNotSupported(node: BaseUrl): NotFoundRoute {
};
}
// The query for a page of releases in one author scope. Older nodes ignore
// `scope`, so ask them for every author too when the scope is untrusted.
export function releasesQuery(scope: ReleaseScope, page: number) {
return {
scope,
allAuthors: scope === "untrusted" ? true : undefined,
page,
perPage: RELEASES_PER_PAGE,
};
}
// Keep only the releases in the author scope. A newer node has done this
// already; an older one answers the untrusted scope with every author. Its pages
// then come back short, so page on the length of the response, not of this.
export function scopeReleases(
releases: Release[],
repo: Repo,
scope: ReleaseScope,
): Release[] {
if (scope === "trusted") {
return releases;
}
const delegateIds = new Set(repo.delegates.map(d => d.id));
return releases.filter(r => !delegateIds.has(r.creator.id));
}
async function loadReleasesView(
route: RepoReleasesRoute,
): Promise<RepoLoadedRoute | NotFoundRoute> {
const api = new HttpdClient(route.node);
const allAuthors = route.allAuthors || false;
// Open on delegates even when they have no releases, so untrusted content
// shows only when asked for.
const scope = route.scope ?? "trusted";
// Fetch releases alongside the repo so the common path stays parallel; the
// repo's `meta.releases` then tells us whether a failure is an unsupported
// node or a genuine error. The delegate scope is a subset of every author,
// so the all-authors view sizes both scopes from its own page, while the
// delegate view has to ask for the other scope to size it.
const [repo, releasesResult, everyAuthorPage, node] = await Promise.all([
// node or a genuine error.
const [repo, releasesResult, node] = await Promise.all([
api.repo.getByRid(route.repo),
api.repo
.getAllReleases(route.repo, {
allAuthors,
page: 0,
perPage: RELEASES_PER_PAGE,
})
.then(
releases => ({ releases }),
(error: unknown) => ({ error }),
),
allAuthors
? undefined
: api.repo
.getAllReleases(route.repo, {
allAuthors: true,
page: 0,
perPage: RELEASES_PER_PAGE,
})
// Only the filter rides on this, so a failure here shouldn't take
// the page down with it.
.catch(() => undefined),
api.repo.getAllReleases(route.repo, releasesQuery(scope, 0)).then(
releases => ({ releases }),
(error: unknown) => ({ error }),
),
api.getNode(),
]);
const releaseCount = repo.payloads["xyz.radicle.project"].meta.releases;
if (releaseCount === undefined) {
// Older nodes report a single count here, newer ones the buckets, and
// either way its absence means no release API.
const releasesMeta = repo.payloads["xyz.radicle.project"].meta.releases;
if (releasesMeta === undefined) {
return releasesNotSupported(route.node);
}
if ("error" in releasesResult) {
throw releasesResult.error;
}
const page = releasesResult.releases;
const releases = releasesResult.releases;
// Offer the author filter only where the two scopes hold different releases,
// so a repo whose releases are all by delegates carries no filter with an
// identical list behind it, and one with none of its own still leaves a way
// through to the others. Both sizes come from a list the endpoint returned:
// `meta.releases` counts every release COB, including ones the endpoint
// hides such as fully redacted releases, so it would overcount either scope.
// A full page means the list may go on, so keep the filter rather than have
// it appear or vanish as the user pages, and keep it too if the other scope
// failed to load. Decided here so it stays fixed for the life of the route.
const delegateIds = new Set(repo.delegates.map(d => d.id));
const delegateReleaseCount = allAuthors
? releases.filter(r => delegateIds.has(r.creator.id)).length
: releases.length;
const everyAuthorCount = allAuthors
? releases.length
: everyAuthorPage?.length;
// Others is one click away whenever it holds releases.
const showFilters =
everyAuthorCount === undefined ||
everyAuthorCount === RELEASES_PER_PAGE ||
delegateReleaseCount !== everyAuthorCount;
typeof releasesMeta !== "number"
? releasesMeta.other > 0
: scope === "untrusted" || (await legacyHasOthers(api, route.repo, repo));
return {
resource: "repo.releases",
params: {
baseUrl: route.node,
repoId: route.repo,
releases,
allAuthors,
releases: scopeReleases(page, repo, scope),
lastPageFull: page.length === RELEASES_PER_PAGE,
scope,
showFilters,
repo,
nodeId: node.id,
@@ -587,11 +585,29 @@ async function loadReleasesView(
};
}
// Older nodes report a single release count without the delegate and
// non-delegate buckets, so look for others in the first page of every author.
// A full page means others may come later, and only the filter depends on
// this, so a full page or a failed fetch keeps the filter. Remove this once
// nodes without the buckets are no longer supported.
async function legacyHasOthers(
api: HttpdClient,
repoId: string,
repo: Repo,
): Promise<boolean> {
const everyAuthor = await api.repo
.getAllReleases(repoId, releasesQuery("untrusted", 0))
.catch(() => undefined);
if (everyAuthor === undefined || everyAuthor.length === RELEASES_PER_PAGE) {
return true;
}
return scopeReleases(everyAuthor, repo, "untrusted").length > 0;
}
async function loadReleaseView(
route: RepoReleaseRoute,
): Promise<RepoLoadedRoute | NotFoundRoute> {
const api = new HttpdClient(route.node);
const allAuthors = route.allAuthors || false;
const [repo, releaseResult, node] = await Promise.all([
api.repo.getByRid(route.repo),
@@ -616,7 +632,7 @@ async function loadReleaseView(
repoId: route.repo,
repo,
release: releaseResult.release,
allAuthors,
scope: route.scope,
nodeId: node.id,
nodeAvatarUrl: node.avatarUrl,
},
@@ -1153,23 +1169,25 @@ export function resolveRepoRoute(
return resolvePatchesRoute(node, repo, segments, urlSearch);
} else if (content === "releases") {
const release = segments.shift();
const allAuthors =
new URLSearchParams(sanitizeQueryString(urlSearch)).get("allAuthors") ===
"true";
const scope =
new URLSearchParams(sanitizeQueryString(urlSearch)).get("scope") ===
"untrusted"
? "untrusted"
: undefined;
if (release) {
return {
resource: "repo.release",
node,
repo,
release,
allAuthors,
scope,
};
} else {
return {
resource: "repo.releases",
node,
repo,
allAuthors,
scope,
};
}
} else {
@@ -1295,14 +1313,14 @@ export function repoRouteToPath(route: RepoRoute): string {
return patchRouteToPath(route);
} else if (route.resource === "repo.releases") {
let url = [...pathSegments, "releases"].join("/");
if (route.allAuthors) {
url += "?allAuthors=true";
if (route.scope === "untrusted") {
url += "?scope=untrusted";
}
return url;
} else if (route.resource === "repo.release") {
let url = [...pathSegments, "releases", route.release].join("/");
if (route.allAuthors) {
url += "?allAuthors=true";
if (route.scope === "untrusted") {
url += "?scope=untrusted";
}
return url;
} else {
+42 -2
View File
@@ -182,12 +182,16 @@ test.describe(() => {
await page.goto(`${peer.uiUrl()}/${rid}/releases/${releaseId}`);
await expect(page.getByText("delegate-artifact")).toBeVisible();
await expect(page.getByText("eve-artifact")).toBeHidden();
await expect(page.getByText("Not from delegates.")).toBeHidden();
await page.getByRole("link", { name: "All 2" }).click();
// The scopes don't overlap: others shows only Eve's artifact.
await page.getByRole("link", { name: "Others 1" }).click();
await expect(page.getByText("Not from delegates.")).toBeVisible();
await expect(page).toHaveURL(
`${peer.uiUrl()}/${rid}/releases/${releaseId}?allAuthors=true`,
`${peer.uiUrl()}/${rid}/releases/${releaseId}?scope=untrusted`,
);
await expect(page.getByText("eve-artifact")).toBeVisible();
await expect(page.getByText("delegate-artifact")).toBeHidden();
await page.getByRole("link", { name: "Delegates 1" }).click();
await expect(page).toHaveURL(
@@ -195,4 +199,40 @@ test.describe(() => {
);
await expect(page.getByText("eve-artifact")).toBeHidden();
});
test("open others' release on delegate artifacts", async ({
page,
peer,
peerManager,
}) => {
const name = "others-release";
const { rid, repoFolder } = await createRepo(peer, { name });
const { contributor, repoFolder: contributorFolder } =
await createContributor(peerManager, peer, { rid, repoName: name });
const { releaseId } = await registerArtifact(
contributor,
contributorFolder,
{ name: "eve-artifact" },
);
await syncFrom(peer, repoFolder);
await registerArtifact(peer, repoFolder, {
name: "delegate-artifact",
release: releaseId,
});
await page.goto(`${peer.uiUrl()}/${rid}/releases?scope=untrusted`);
await page.locator(`a[href$="/releases/${releaseId}"]`).click();
await expect(page).toHaveURL(
`${peer.uiUrl()}/${rid}/releases/${releaseId}`,
);
await expect(page.getByText("Not from a delegate.")).toBeVisible();
await expect(page.getByText("delegate-artifact")).toBeVisible();
await expect(page.getByText("eve-artifact")).toBeHidden();
// The breadcrumb leads back to the others' releases.
await page.locator('a[href$="/releases?scope=untrusted"]').click();
await expect(page).toHaveURL(
`${peer.uiUrl()}/${rid}/releases?scope=untrusted`,
);
});
});
+43 -6
View File
@@ -58,11 +58,12 @@ test.describe(() => {
await expect(page.locator(".release-teaser")).toHaveCount(1);
await expect(page.getByText("eve commit")).toBeHidden();
await page.getByRole("link", { name: "All 2" }).click();
// The scopes don't overlap: others shows only Eve's release.
await page.getByRole("link", { name: "Others 1" }).click();
await expect(page).toHaveURL(
`${peer.uiUrl()}/${rid}/releases?allAuthors=true`,
`${peer.uiUrl()}/${rid}/releases?scope=untrusted`,
);
await expect(page.locator(".release-teaser")).toHaveCount(2);
await expect(page.locator(".release-teaser")).toHaveCount(1);
await expect(page.getByText("eve commit")).toBeVisible();
await page.getByRole("link", { name: "Delegates 1" }).click();
@@ -70,6 +71,41 @@ test.describe(() => {
await expect(page.locator(".release-teaser")).toHaveCount(1);
});
test("open on delegates when only others released", async ({
page,
peer,
peerManager,
}) => {
const name = "release-others-only";
const { rid, repoFolder } = await createRepo(peer, { name });
const { contributor, repoFolder: contributorFolder } =
await createContributor(peerManager, peer, { rid, repoName: name });
await contributor.git(
["commit", "--allow-empty", "--message", "eve commit"],
{ cwd: contributorFolder },
);
await contributor.git(["push", "rad", "HEAD:refs/heads/eve"], {
cwd: contributorFolder,
});
const { stdout: eveHead } = await contributor.git(["rev-parse", "HEAD"], {
cwd: contributorFolder,
});
await registerArtifact(contributor, contributorFolder, {
name: "eve-binary",
revision: eveHead,
});
await syncFrom(peer, repoFolder);
// Untrusted content shows only when asked for, even with nothing else.
await page.goto(`${peer.uiUrl()}/${rid}/releases`);
await expect(page.getByText("No releases by delegates")).toBeVisible();
await expect(page.getByText("eve commit")).toBeHidden();
await page.getByRole("link", { name: "Others 1" }).click();
await expect(page.getByText("eve commit")).toBeVisible();
});
test("empty release listing", async ({ page, peer }) => {
const { rid } = await createRepo(peer, { name: "no-releases" });
@@ -97,9 +133,10 @@ test.describe(() => {
await page.goto(`${peer.uiUrl()}/${rid}/releases`);
await expect(page.getByText("No releases", { exact: true })).toBeVisible();
// The tab counter comes from the repo metadata, which counts every release
// COB, redacted or not.
await expect(page.getByRole("link", { name: "Releases 1" })).toBeVisible();
// The tab counter comes from the repo metadata, which counts only the
// releases left with an unredacted artifact, so it agrees with the empty
// listing. The tab itself stays, since the node does serve releases.
await expect(page.getByRole("link", { name: "Releases 0" })).toBeVisible();
});
});
+137
View File
@@ -0,0 +1,137 @@
import type { Artifact } from "@http-client";
import { describe, expect, test } from "vitest";
import {
artifactView,
attestedBy,
redactedByTrusted,
releaseWarning,
} from "@app/lib/releases";
const delegate = "did:key:z6MkDelegate";
const other = "did:key:z6MkOther";
const delegates = new Set([delegate]);
function artifact(
cid: string,
author: string,
redactedBy: string[] = [],
): Artifact {
return {
cid,
author: { id: author },
redactions: redactedBy.map(id => ({ user: { id } })),
} as Artifact;
}
describe("redactedByTrusted", () => {
test.each([
{ by: [delegate], author: other, expected: true },
{ by: [other], author: other, expected: true },
{ by: [other], author: delegate, expected: false },
{ by: [], author: other, expected: false },
])("redacted by $by for $author => $expected", ({ by, author, expected }) => {
expect(redactedByTrusted(artifact("a", author, by), delegates)).toBe(
expected,
);
});
});
describe("artifactView", () => {
const ours = artifact("ours", delegate);
const ourRedacted = artifact("ours-redacted", delegate, [delegate]);
const theirs = artifact("theirs", other);
test("splits artifacts into disjoint scopes", () => {
const all = [ours, theirs];
expect(artifactView(all, delegates, "trusted", false).shown).toEqual([
ours,
]);
expect(artifactView(all, delegates, "untrusted", false).shown).toEqual([
theirs,
]);
});
test("keeps the asked scope when it is empty", () => {
const view = artifactView([theirs], delegates, "trusted", false);
expect(view.shown).toEqual([]);
expect(view.showFilters).toBe(true);
});
test("hides the filter without artifacts by others", () => {
const view = artifactView([ours], delegates, "trusted", false);
expect(view.showFilters).toBe(false);
});
test("hides redacted artifacts unless asked", () => {
const all = [ours, ourRedacted];
expect(artifactView(all, delegates, "trusted", false).shown).toEqual([
ours,
]);
expect(artifactView(all, delegates, "trusted", true).shown).toEqual(all);
});
test("counts redacted artifacts in the current scope only", () => {
const all = [ourRedacted, artifact("x", other, [other])];
expect(artifactView(all, delegates, "trusted", false).redactedCount).toBe(
1,
);
});
test("counts only what each scope would show", () => {
const view = artifactView(
[ours, ourRedacted, theirs],
delegates,
"trusted",
false,
);
expect(view.counts).toEqual({ trusted: 1, untrusted: 1 });
});
test("keeps the filter when only redacted artifacts by others exist", () => {
const view = artifactView(
[ours, artifact("x", other, [other])],
delegates,
"trusted",
false,
);
expect(view.showFilters).toBe(true);
});
test("handles a release without artifacts", () => {
const view = artifactView([], delegates, "trusted", false);
expect(view).toEqual({
shown: [],
redactedCount: 0,
counts: { trusted: 0, untrusted: 0 },
showFilters: false,
});
});
});
describe("attestedBy", () => {
test("counts only delegate attestations", () => {
expect(attestedBy([{ id: delegate }, { id: other }], delegates)).toBe(1);
});
test("counts nothing without attestations", () => {
expect(attestedBy([], delegates)).toBe(0);
});
});
describe("releaseWarning", () => {
test.each(["trusted" as const, "untrusted" as const])(
"warns about a release by a non-delegate in the %s scope",
scope => {
expect(releaseWarning(other, delegates, scope)).toBe("release");
},
);
test("warns about others' artifacts on a delegate's release", () => {
expect(releaseWarning(delegate, delegates, "untrusted")).toBe("artifacts");
});
test("trusts a delegate's release with delegate artifacts", () => {
expect(releaseWarning(delegate, delegates, "trusted")).toBeUndefined();
});
});
+27
View File
@@ -110,6 +110,33 @@ describe("route invariant when parsed", () => {
});
});
test("repos.releases", () => {
expectParsingInvariant({
resource: "repo.releases",
node,
repo: "REPO",
});
});
test("repos.releases with untrusted scope", () => {
expectParsingInvariant({
resource: "repo.releases",
node,
repo: "REPO",
scope: "untrusted",
});
});
test("repos.release with untrusted scope", () => {
expectParsingInvariant({
resource: "repo.release",
node,
repo: "REPO",
release: "RELEASE",
scope: "untrusted",
});
});
test("repos.issues with status", () => {
expectParsingInvariant({
resource: "repo.issues",