diff --git a/AGENTS.md b/AGENTS.md index 8fc7d20b..cbd4574f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -134,10 +134,12 @@ machines whose "disk" is 20 GiB of tmpfs, next to a long tail of small repositor | `leases/.pb` | CAS lease with TTL heartbeat: `compact`. The only cross-instance mutex. | | `cache/api/v1/.json` | Shared render cache of immutable web API answers. | | `policy.json` | Per-repo push policy (rule language, not on the WAL). `docs/POLICY.md`. Missing = allow-all. | +| `description.json` | Per-repo human-readable description (`{"description"}`, not on the WAL, never read by git; D50). Missing = none. | | `fsck.pb` | Last connectivity audit (`FsckReport`), written by the maintainer's `fsck` unit, consumed by `repair` (`docs/INTEGRITY.md`). | | `events/cursor.json` | Durable acknowledged WAL sequence of the events bridge; advanced only after the webhook acknowledged (D32). | | `lfs/objects///` | LFS objects (sha256-addressed, immutable). Missing ones can be read through from `upstream.lfs` and persisted (`docs/LFS.md`). | -Schema `crates/walgit-proto/proto/walgit/v1/wal.proto`; GCS over gRPC, S3 (AWS SDK) and in-memory stores share +Outside `repos/`: `owners//profile.json` (an owner's display name + description, D50), `maintain/.pb` +(maintainer heartbeats). Schema `crates/walgit-proto/proto/walgit/v1/wal.proto`; GCS over gRPC, S3 (AWS SDK) and in-memory stores share one contract suite (`crates/walgit-store/tests/contract.rs`, incl. compose). ### 2.2 Write path @@ -289,7 +291,7 @@ Unrelated constraints remain in force. The current design target and migration g - **D13** Long work is a task and is narrated (§2.7); no endpoint may block silently. - **D14** Web toolchain: pnpm + Vite only. The build fails unless the optimized SPA artefacts are present. - **D15** Repo-scoped API lives at `/{o}/{r}/api/…` (refs, resolve, tree, blob, commits, commit, overview, ops, - tasks, policy, settings) so the repository prefix determines placement. The browser lane is + tasks, policy, description, settings) so the repository prefix determines placement. The browser lane is `/{o}/{r}/api-browser/…`; `/api/v1` is non-repository discovery, identity and owner listing. No aliases. - **D16** Push authorization is a per-repo **rule language** at `repos///policy.json` (`docs/POLICY.md`). Envelope `version` + `groups` + `rules`; `protect` = AND. Empty/missing = anyone with write may move any ref. @@ -487,6 +489,18 @@ full cold-read/resource acceptance gates listed in `docs/spec/README.md`. and candidate external-boundary proof remain separate obligations; local loose objects and retired download membership cannot justify retirement. See the cost and remaining-evidence rows in the linked docs. +- **D50 (2026-09-30): Human-readable metadata is two small control objects, not state.** A repository's + description (`repos///description.json`) and an owner's profile (`owners//profile.json`: + `display_name`, `description`) give opaque ids a readable name. Like `policy.json` (D16) they are outside the + WAL — a label is not repository state, advances nothing, rides no sync and is read by no git path — and are + overwritten last-writer-wins. The profile sits at the bucket root because `repos/` holds repositories only (its + delimited listing finds them) and an owner is not a routing unit (D26). Owners stay implicit: a profile creates + nothing and is listed only while its owner has a repository. Surface: `GET|PUT|DELETE /{o}/{r}/api/description`, + `GET|PUT|DELETE /api[-browser]/v1/owners/{o}`, `?detail=1` on both owner listings (plain shapes unchanged); + read for GET, **admin** for every write, including `?description=` on create. One line of plain text, + bounded; strict on write, lenient on read. No in-process cache (principle IV); the per-item GETs of a detail + listing are the one multiplied cost (`docs/ROUNDTRIPS.md`). Git's own `description` file is not written. + ## 5. Working rules - **No backwards compatibility (pre-1.0, banner at top):** change the shape and delete the old one in the same diff --git a/crates/walgit-cli/src/lib.rs b/crates/walgit-cli/src/lib.rs index c7b49b35..432a6d7a 100644 --- a/crates/walgit-cli/src/lib.rs +++ b/crates/walgit-cli/src/lib.rs @@ -236,6 +236,17 @@ enum RepoAction { /// `owner/name`. repo: String, }, + /// Print, set or clear the human-readable description (`description.json` in the bucket). + Describe { + /// `owner/name`. + repo: String, + /// Replace the description (one line of plain text, at most 512 characters). + #[arg(long, conflicts_with = "clear")] + set: Option, + /// Remove the description. + #[arg(long)] + clear: bool, + }, /// Per-repo push policy (`policy.json` in the bucket). Policy { #[command(subcommand)] diff --git a/crates/walgit-cli/src/repo.rs b/crates/walgit-cli/src/repo.rs index 1d17fb87..e1d6d3b1 100644 --- a/crates/walgit-cli/src/repo.rs +++ b/crates/walgit-cli/src/repo.rs @@ -1,4 +1,4 @@ -//! `walgit repo create|list|info` — repository management. +//! `walgit repo create|list|info|describe` — repository management. use std::sync::Arc; @@ -90,6 +90,7 @@ pub async fn run(action: RepoAction, cfg: &Arc) -> Result<()> { ); } } + RepoAction::Describe { repo, set, clear } => describe(&repo, set, clear, &store).await?, RepoAction::Policy { action } => policy(action, &store).await?, RepoAction::Settings { .. } => unreachable!(), } @@ -120,6 +121,44 @@ async fn policy(action: PolicyAction, store: &walgit_store::DynStore) -> Result< Ok(()) } +/// Straight against the bucket, like `policy`: the host that runs this is trusted with +/// the store. Only an existing repository is labelled (one manifest HEAD), so a typo does +/// not leave a stray object under a prefix nothing lists. +async fn describe( + repo: &str, + set: Option, + clear: bool, + store: &walgit_store::DynStore, +) -> Result<()> { + use walgit_server::metadata; + let id = repo_id(repo)?; + if set.is_some() || clear { + let manifest = format!("{}{}", id.store_prefix(), walgit_proto::keys::MANIFEST); + if store.head(&manifest).await?.is_none() { + bail!("no repository {id}"); + } + } + if let Some(text) = set { + let description = + metadata::clean_text("description", &text, metadata::DESCRIPTION_MAX_CHARS) + .map_err(anyhow::Error::msg)?; + let doc = metadata::RepoDescription { + description: Some(description), + }; + metadata::save_description(store, &id, &doc).await?; + info!(repo = %id, "description saved"); + } else if clear { + metadata::clear_description(store, &id).await?; + info!(repo = %id, "description cleared"); + } else { + match metadata::load_description(store, &id).await?.description { + Some(d) => println!("{d}"), + None => println!("(none)"), + } + } + Ok(()) +} + fn repo_id(repo: &str) -> Result { let (owner, name) = parse_repo_id(repo)?; Ok(walgit_git::RepoId::new(owner, name)?) diff --git a/crates/walgit-git/src/lib.rs b/crates/walgit-git/src/lib.rs index d1ce0ce8..665a0ef9 100644 --- a/crates/walgit-git/src/lib.rs +++ b/crates/walgit-git/src/lib.rs @@ -184,6 +184,13 @@ impl RepoId { } } +/// An owner name alone, by the rule a [`RepoId`] applies to its first part. Owners are +/// implicit (a prefix of repository ids), so this is for surfaces addressed by owner only +/// (the owner profile) that must accept exactly the owners a repository could have. +pub fn validate_owner(owner: &str) -> Result<(), GitError> { + validate_part(owner, "owner") +} + fn validate_part(s: &str, what: &str) -> Result<(), GitError> { if s.is_empty() || s.len() > 100 { return Err(GitError::InvalidInput(format!( diff --git a/crates/walgit-proto/src/lib.rs b/crates/walgit-proto/src/lib.rs index 769bcf8a..bc575bd1 100644 --- a/crates/walgit-proto/src/lib.rs +++ b/crates/walgit-proto/src/lib.rs @@ -47,6 +47,19 @@ pub mod keys { pub fn policy_key(owner: &str, name: &str) -> String { format!("{}{POLICY}", repo_prefix(owner, name)) } + /// Per-repo human description (JSON). Not on the WAL; overwritten, never on a git path. + pub const DESCRIPTION: &str = "description.json"; + + pub fn description_key(owner: &str, name: &str) -> String { + format!("{}{DESCRIPTION}", repo_prefix(owner, name)) + } + /// Bucket-root prefix of owner profiles (not under `repos/`: that prefix holds + /// repositories only, and its delimited listing is how repositories are found). + pub const OWNERS_DIR: &str = "owners/"; + /// `owners//profile.json` — the owner's display name and description (JSON). + pub fn owner_profile_key(owner: &str) -> String { + format!("{OWNERS_DIR}{owner}/profile.json") + } /// `log/.pb` pub fn log_segment_key(first_seq: u64) -> String { @@ -195,6 +208,11 @@ mod tests { "checkpoints/0000000000000001/checkpoint.pb" ); assert_eq!(keys::lfs_key("abcdef"), "lfs/objects/ab/cd/abcdef"); + assert_eq!( + keys::description_key("acme", "mono"), + "repos/acme/mono/description.json" + ); + assert_eq!(keys::owner_profile_key("acme"), "owners/acme/profile.json"); assert!(!keys::lfs_oid_ok("ab")); assert!(!keys::lfs_oid_ok("abcdef")); assert!(keys::lfs_oid_ok(&"a".repeat(64))); diff --git a/crates/walgit-server/src/admin.rs b/crates/walgit-server/src/admin.rs index d9f2863b..90e3cb0e 100644 --- a/crates/walgit-server/src/admin.rs +++ b/crates/walgit-server/src/admin.rs @@ -9,14 +9,28 @@ use crate::AppState; use crate::error::ApiError; use crate::repo::RepoRoute; -/// `PUT /{owner}/{repo}` — create repo. 201 on new, 409 if it exists. +/// `PUT /{owner}/{repo}[?object_format=sha1|sha256][&description=…]` — create repo. 201 on +/// new, 409 if it exists. `description` (percent-encoded, `crate::metadata` rules) is +/// validated before anything is created and written once the create succeeded. It needs +/// **admin**, like every other description write: a create is not proof of a *new* +/// repository (`Registry::create` answers `Ok` with the cached handle when this instance +/// already has the repository open), so write permission alone must never be able to +/// relabel an existing one. Without `description`, create stays a write operation. pub async fn create( st: &AppState, route: &RepoRoute, headers: &HeaderMap, query: &str, ) -> Result { - let _principal = st.auth.require_write(headers).await.map_err(auth_err)?; + let labelled = query + .split('&') + .any(|part| part.starts_with("description=")); + let _principal = if labelled { + st.auth.require_admin(headers).await + } else { + st.auth.require_write(headers).await + } + .map_err(auth_err)?; let format = match query .split('&') .find_map(|part| part.strip_prefix("object_format=")) @@ -30,8 +44,36 @@ pub async fn create( } None => ObjectFormat::from(st.cfg.git.object_format), }; + let description = query + .split('&') + .find_map(|part| part.strip_prefix("description=")) + .map(|v| { + crate::metadata::clean_text( + "description", + &crate::settings::percent_decode(v), + crate::metadata::DESCRIPTION_MAX_CHARS, + ) + .map_err(ApiError::BadRequest) + }) + .transpose()?; match st.registry.create(&route.id, format).await { - Ok(_h) => Ok((StatusCode::CREATED, "created").into_response()), + Ok(_h) => { + if let Some(description) = description { + let doc = crate::metadata::RepoDescription { + description: Some(description), + }; + // The repository exists now; say so truthfully if its label did not land. + crate::metadata::save_description(&st.store, &route.id, &doc) + .await + .map_err(|e| { + ApiError::Internal(format!( + "repository created, description not saved ({e}); PUT {}/api/description to retry", + route.id + )) + })?; + } + Ok((StatusCode::CREATED, "created").into_response()) + } Err(walgit_wal::WalError::AlreadyExists) => { Ok((StatusCode::CONFLICT, "already exists").into_response()) } diff --git a/crates/walgit-server/src/lib.rs b/crates/walgit-server/src/lib.rs index 5f6c6154..599fece7 100644 --- a/crates/walgit-server/src/lib.rs +++ b/crates/walgit-server/src/lib.rs @@ -43,6 +43,7 @@ pub mod instance; pub mod lfs; pub mod lfs_upstream; pub mod maintain; +pub mod metadata; pub mod metrics; pub mod middleware; pub mod ops; @@ -403,6 +404,15 @@ pub(crate) async fn dispatch_route( policy::http_put(st, route, &headers, body.take().unwrap()).await } (&Method::DELETE, "policy") => policy::http_delete(st, route, &headers).await, + (&Method::GET, "description") => { + metadata::http_get_description(st, route, &headers).await + } + (&Method::PUT, "description") => { + metadata::http_put_description(st, route, &headers, body.take().unwrap()).await + } + (&Method::DELETE, "description") => { + metadata::http_delete_description(st, route, &headers).await + } (&Method::GET, "settings") => settings::http_get(st, route, &headers).await, (&Method::GET, "settings/effective") => { settings::http_effective(st, route, &headers).await diff --git a/crates/walgit-server/src/metadata.rs b/crates/walgit-server/src/metadata.rs new file mode 100644 index 00000000..47f0f205 --- /dev/null +++ b/crates/walgit-server/src/metadata.rs @@ -0,0 +1,572 @@ +//! Human-readable metadata beside the ids: a repository's **description** and an +//! owner's **profile** (display name + description). +//! +//! Everything in walgit is keyed by `owner/repo`. A deployment whose owners and +//! repositories are opaque ids (UUIDs from another system) has nowhere else to keep the +//! name a person reads, so the owner list and repo lists are unreadable. These are two +//! small control objects in the bucket, shaped like `policy.json` (D16) and for the same +//! reasons *not* on the WAL: a label is not repository state, must not advance +//! `head_seq`, must not ride every refs-level sync, and must never be read by git paths. +//! +//! | Object | Key | Document | +//! |---|---|---| +//! | repository description | `repos///description.json` | `{"description": "…"}` | +//! | owner profile | `owners//profile.json` | `{"display_name": "…", "description": "…"}` (each optional, at least one) | +//! +//! The owner profile lives at the bucket root, not under `repos//`: `repos/` holds +//! repositories only and its delimited listing is how the registry finds them +//! (`Registry::list`), and an owner is not a routing unit (D26) — its surface is the +//! non-repository `/api/v1/owners/{owner}`. **Owners stay implicit**: a profile does not +//! create an owner, listings still derive owners from repository ids, and a profile whose +//! owner has no repository is simply not listed (it reappears with the first repository — +//! which is what lets an operator provision the name before the first push). +//! +//! Reads revalidate every time (principle IV): one GET per object, no in-process cache — +//! there is none for small control objects to reuse (`policy.json` is read the same way), +//! and a TTL here would be exactly the invented staleness the principle forbids. The one +//! place that multiplies GETs is `?detail=1` on the owner/repo listings: one GET per listed +//! item, `DETAIL_CONCURRENCY` in flight, only on that opt-in call and never on a git path +//! (`docs/ROUNDTRIPS.md` §2). Writes are last-writer-wins overwrites (the same as +//! `policy.json`): there is nothing to merge in a label. +//! +//! Validation is strict on write (unknown keys, non-strings, control characters, length) +//! and lenient on read: unknown keys are ignored so two binaries can share one bucket +//! during a roll, and an object that does not parse or is implausibly large is logged and +//! served as absent rather than failing a listing. + +use axum::http::{HeaderMap, StatusCode}; +use axum::response::{IntoResponse, Response}; +use futures::{StreamExt, TryStreamExt}; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; +use walgit_git::RepoId; +use walgit_proto::keys; +use walgit_store::{DynStore, GetOptions, GetResult, PutBody, PutMode, StoreError}; + +use crate::AppState; +use crate::error::ApiError; +use crate::repo::RepoRoute; +use crate::web::api::{auth_err, json_swr_digest}; + +/// A repository or owner description, in characters (GitHub's is 350; a line, not a README). +pub const DESCRIPTION_MAX_CHARS: usize = 512; +/// An owner's display name, in characters. +pub const DISPLAY_NAME_MAX_CHARS: usize = 100; +/// Upper bound on a request body or a stored object before it is parsed. Generous for the +/// limits above (4 bytes per char, JSON escaping), small enough that a hand-written or +/// hostile object cannot make a listing download megabytes per item. +pub const MAX_DOCUMENT_BYTES: usize = 8 * 1024; +/// GETs in flight for one `?detail=1` listing — the registry's manifest-HEAD fan-out width +/// (`Registry::list`), on the control-plane transport (D19), never with bulk bytes. +pub const DETAIL_CONCURRENCY: usize = 32; + +// --------------------------------------------------------------------------- +// Documents +// --------------------------------------------------------------------------- + +/// `repos///description.json`. Missing object = `{}`. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct RepoDescription { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, +} + +/// `owners//profile.json`. Missing object = `{}`. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct OwnerProfile { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub display_name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, +} + +impl OwnerProfile { + pub fn is_empty(&self) -> bool { + self.display_name.is_none() && self.description.is_none() + } +} + +/// One row of `GET /api/v1/owners?detail=1`, and the body of `GET /api/v1/owners/{owner}`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct OwnerEntry { + pub name: String, + #[serde(flatten)] + pub profile: OwnerProfile, +} + +/// One row of `GET /api/v1/owners/{owner}/repos?detail=1`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct RepoEntry { + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, +} + +/// Plain text on one line: trimmed, non-empty, no control characters (a newline in a +/// list row or a `` is a layout bug waiting to happen; markup is not interpreted — +/// the UI renders text), at most `max` characters. +pub fn clean_text(field: &str, value: &str, max: usize) -> Result<String, String> { + let v = value.trim(); + if v.is_empty() { + return Err(format!("{field} is empty (DELETE clears it)")); + } + if v.chars().any(char::is_control) { + return Err(format!( + "{field} must be plain text on one line (no control characters)" + )); + } + let n = v.chars().count(); + if n > max { + return Err(format!("{field} is {n} characters; the limit is {max}")); + } + Ok(v.to_string()) +} + +/// Strict write-side parse: a JSON object whose keys are all in `allowed` and whose values +/// are strings. A typo (`displayName`) must be a 400, not a silently empty field. +fn parse_object(bytes: &[u8], allowed: &[&str]) -> Result<Map<String, Value>, String> { + if bytes.len() > MAX_DOCUMENT_BYTES { + return Err(format!( + "document is {} bytes; the limit is {MAX_DOCUMENT_BYTES}", + bytes.len() + )); + } + let Value::Object(map) = + serde_json::from_slice::<Value>(bytes).map_err(|e| format!("invalid JSON: {e}"))? + else { + return Err("expected a JSON object".into()); + }; + for (k, v) in &map { + if !allowed.contains(&k.as_str()) { + return Err(format!( + "unknown key {k:?} (expected {})", + allowed.join(", ") + )); + } + if !v.is_string() { + return Err(format!("{k} must be a string")); + } + } + Ok(map) +} + +fn text_field(map: &Map<String, Value>, key: &str, max: usize) -> Result<Option<String>, String> { + map.get(key) + .and_then(Value::as_str) + .map(|s| clean_text(key, s, max)) + .transpose() +} + +/// `PUT …/description` body → a validated description. +pub fn parse_description(bytes: &[u8]) -> Result<RepoDescription, String> { + let map = parse_object(bytes, &["description"])?; + let description = text_field(&map, "description", DESCRIPTION_MAX_CHARS)? + .ok_or("missing \"description\" (DELETE clears it)")?; + Ok(RepoDescription { + description: Some(description), + }) +} + +/// `PUT /api/v1/owners/{owner}` body → a validated profile. `name` is accepted so a +/// client can PUT back what it GOT, but only when it is the owner in the path: the id is +/// the key, never a field to rename. +pub fn parse_profile(owner: &str, bytes: &[u8]) -> Result<OwnerProfile, String> { + let map = parse_object(bytes, &["display_name", "description", "name"])?; + if let Some(name) = map.get("name").and_then(Value::as_str) + && name != owner + { + return Err(format!( + "name {name:?} differs from the owner in the path ({owner:?}); the id cannot be changed" + )); + } + let profile = OwnerProfile { + display_name: text_field(&map, "display_name", DISPLAY_NAME_MAX_CHARS)?, + description: text_field(&map, "description", DESCRIPTION_MAX_CHARS)?, + }; + if profile.is_empty() { + return Err("empty profile: set display_name and/or description (DELETE clears it)".into()); + } + Ok(profile) +} + +// --------------------------------------------------------------------------- +// Store +// --------------------------------------------------------------------------- + +/// GET a small JSON control object; absent, oversized or unparsable = `T::default()`. +/// One request on every path: a 404 is the "none" answer, never a HEAD first. +async fn load_json<T: for<'de> Deserialize<'de> + Default>( + store: &DynStore, + key: &str, +) -> Result<T, StoreError> { + let got = match store.get(key, GetOptions::default()).await { + Ok(got) => got, + Err(StoreError::NotFound { .. }) => return Ok(T::default()), + Err(e) => return Err(e), + }; + if let GetResult::Object { meta, .. } = &got + && meta.size > MAX_DOCUMENT_BYTES as u64 + { + tracing::warn!(key, size = meta.size, "metadata object too large; ignored"); + return Ok(T::default()); + } + let Some((_, bytes)) = got.bytes().await? else { + return Ok(T::default()); + }; + Ok(serde_json::from_slice(&bytes).unwrap_or_else(|e| { + tracing::warn!(key, error = %e, "metadata object does not parse; ignored"); + T::default() + })) +} + +async fn save_json<T: Serialize>(store: &DynStore, key: &str, doc: &T) -> Result<(), StoreError> { + let body = serde_json::to_vec(doc) + .map_err(|e| StoreError::InvalidArgument(format!("encode {key}: {e}")))?; + store + .put(key, PutBody::from(body), PutMode::Overwrite.into()) + .await?; + Ok(()) +} + +async fn clear_key(store: &DynStore, key: &str) -> Result<(), StoreError> { + match store.delete(key, None).await { + Ok(()) | Err(StoreError::NotFound { .. }) => Ok(()), + Err(e) => Err(e), + } +} + +pub async fn load_description( + store: &DynStore, + id: &RepoId, +) -> Result<RepoDescription, StoreError> { + load_json(store, &keys::description_key(id.owner(), id.name())).await +} +pub async fn save_description( + store: &DynStore, + id: &RepoId, + doc: &RepoDescription, +) -> Result<(), StoreError> { + save_json(store, &keys::description_key(id.owner(), id.name()), doc).await +} +pub async fn clear_description(store: &DynStore, id: &RepoId) -> Result<(), StoreError> { + clear_key(store, &keys::description_key(id.owner(), id.name())).await +} + +/// `owner` must already be valid (`walgit_git::validate_owner`): it becomes a key segment. +pub async fn load_profile(store: &DynStore, owner: &str) -> Result<OwnerProfile, StoreError> { + load_json(store, &keys::owner_profile_key(owner)).await +} +pub async fn save_profile( + store: &DynStore, + owner: &str, + doc: &OwnerProfile, +) -> Result<(), StoreError> { + save_json(store, &keys::owner_profile_key(owner), doc).await +} +pub async fn clear_profile(store: &DynStore, owner: &str) -> Result<(), StoreError> { + clear_key(store, &keys::owner_profile_key(owner)).await +} + +/// Profiles for sorted owner names, same order: one GET each, `DETAIL_CONCURRENCY` in +/// flight (`buffered`, not `buffer_unordered`, so the listing's order survives). +pub async fn owner_details( + store: &DynStore, + owners: Vec<String>, +) -> Result<Vec<OwnerEntry>, StoreError> { + futures::stream::iter(owners) + .map(|name| { + let store = store.clone(); + async move { + let profile = load_profile(&store, &name).await?; + Ok::<_, StoreError>(OwnerEntry { name, profile }) + } + }) + .buffered(DETAIL_CONCURRENCY) + .try_collect() + .await +} + +/// Descriptions for one owner's sorted repositories, same order and fan-out as above. +pub async fn repo_details( + store: &DynStore, + repos: Vec<RepoId>, +) -> Result<Vec<RepoEntry>, StoreError> { + futures::stream::iter(repos) + .map(|id| { + let store = store.clone(); + async move { + let doc = load_description(&store, &id).await?; + Ok::<_, StoreError>(RepoEntry { + name: id.name().to_string(), + description: doc.description, + }) + } + }) + .buffered(DETAIL_CONCURRENCY) + .try_collect() + .await +} + +// --------------------------------------------------------------------------- +// HTTP: /{o}/{r}/api[-browser]/description (via `crate::dispatch_route`) +// --------------------------------------------------------------------------- + +/// `GET` → `{"description"?}`; 404 for an unknown repository. The repository check and +/// the object GET run concurrently: depth 1 GET on a warm handle. +pub async fn http_get_description( + st: &AppState, + route: &RepoRoute, + headers: &HeaderMap, +) -> Result<Response, ApiError> { + st.auth.require_read(headers).await.map_err(auth_err)?; + let (exists, doc) = tokio::join!( + crate::policy::ensure_repo(st, route), + load_description(&st.store, &route.id) + ); + exists?; + let doc = doc.map_err(store_err)?; + Ok(json_swr_digest(&doc).into_response(headers)) +} + +/// `PUT` (admin) body `{"description": "…"}` → 204. +pub async fn http_put_description( + st: &AppState, + route: &RepoRoute, + headers: &HeaderMap, + body: axum::body::Body, +) -> Result<Response, ApiError> { + st.auth.require_admin(headers).await.map_err(auth_err)?; + let bytes = read_body(body).await?; + let doc = parse_description(&bytes).map_err(ApiError::BadRequest)?; + crate::policy::ensure_repo(st, route).await?; + save_description(&st.store, &route.id, &doc) + .await + .map_err(store_err)?; + Ok((StatusCode::NO_CONTENT, "").into_response()) +} + +/// `DELETE` (admin) → 204, also when there was none. +pub async fn http_delete_description( + st: &AppState, + route: &RepoRoute, + headers: &HeaderMap, +) -> Result<Response, ApiError> { + st.auth.require_admin(headers).await.map_err(auth_err)?; + crate::policy::ensure_repo(st, route).await?; + clear_description(&st.store, &route.id) + .await + .map_err(store_err)?; + Ok((StatusCode::NO_CONTENT, "").into_response()) +} + +// --------------------------------------------------------------------------- +// HTTP: /api/v1/owners/{owner} and /api-browser/v1/owners/{owner} (web::v1) +// --------------------------------------------------------------------------- + +fn owner_arg(owner: &str) -> Result<(), ApiError> { + walgit_git::validate_owner(owner).map_err(|e| ApiError::BadRequest(e.to_string())) +} + +/// `GET` → `{name, display_name?, description?}`. 200 for any valid owner name, with or +/// without repositories or a profile — the same rule as `owners/{o}/repos` answering `[]`. +pub async fn http_get_profile( + st: &AppState, + owner: &str, + headers: &HeaderMap, +) -> Result<Response, ApiError> { + st.auth.require_read(headers).await.map_err(auth_err)?; + owner_arg(owner)?; + let profile = load_profile(&st.store, owner).await.map_err(store_err)?; + let entry = OwnerEntry { + name: owner.to_string(), + profile, + }; + Ok(json_swr_digest(&entry).into_response(headers)) +} + +/// `PUT` (admin) body `{display_name?, description?}` → 204. Allowed for an owner with no +/// repositories yet (it is not listed until one exists; nothing is created but the object). +pub async fn http_put_profile( + st: &AppState, + owner: &str, + headers: &HeaderMap, + body: axum::body::Body, +) -> Result<Response, ApiError> { + st.auth.require_admin(headers).await.map_err(auth_err)?; + owner_arg(owner)?; + let bytes = read_body(body).await?; + let profile = parse_profile(owner, &bytes).map_err(ApiError::BadRequest)?; + save_profile(&st.store, owner, &profile) + .await + .map_err(store_err)?; + Ok((StatusCode::NO_CONTENT, "").into_response()) +} + +/// `DELETE` (admin) → 204, also when there was none. +pub async fn http_delete_profile( + st: &AppState, + owner: &str, + headers: &HeaderMap, +) -> Result<Response, ApiError> { + st.auth.require_admin(headers).await.map_err(auth_err)?; + owner_arg(owner)?; + clear_profile(&st.store, owner).await.map_err(store_err)?; + Ok((StatusCode::NO_CONTENT, "").into_response()) +} + +/// Bounded body read: 413 above `MAX_DOCUMENT_BYTES` without buffering more than that. +async fn read_body(body: axum::body::Body) -> Result<bytes::Bytes, ApiError> { + axum::body::to_bytes(body, MAX_DOCUMENT_BYTES) + .await + .map_err(|_| ApiError::PayloadTooLarge) +} + +fn store_err(e: StoreError) -> ApiError { + match e { + StoreError::InvalidArgument(msg) => ApiError::BadRequest(msg), + e => ApiError::Internal(format!("store: {e}")), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn description_is_one_bounded_line() { + let d = parse_description(br#"{"description":" Payments ledger "}"#).unwrap(); + assert_eq!(d.description.as_deref(), Some("Payments ledger")); + for bad in [ + &br#"{"description":""}"#[..], + br#"{"description":" "}"#, + br#"{"description":"a\nb"}"#, + br#"{"description":7}"#, + br#"{"descripton":"typo"}"#, + br"{}", + br#"["description"]"#, + b"not json", + ] { + assert!( + parse_description(bad).is_err(), + "{}", + String::from_utf8_lossy(bad) + ); + } + let at_limit = format!( + r#"{{"description":"{}"}}"#, + "é".repeat(DESCRIPTION_MAX_CHARS) + ); + assert!( + parse_description(at_limit.as_bytes()).is_ok(), + "limit counts characters, not bytes" + ); + let over = format!( + r#"{{"description":"{}"}}"#, + "x".repeat(DESCRIPTION_MAX_CHARS + 1) + ); + assert!(parse_description(over.as_bytes()).is_err()); + let huge = vec![b' '; MAX_DOCUMENT_BYTES + 1]; + assert!(parse_description(&huge).unwrap_err().contains("limit")); + } + + #[test] + fn profile_fields_optional_but_not_all_absent() { + let p = parse_profile("u-1", br#"{"display_name":"Platform team"}"#).unwrap(); + assert_eq!(p.display_name.as_deref(), Some("Platform team")); + assert_eq!(p.description, None); + let p = parse_profile( + "u-1", + br#"{"name":"u-1","display_name":"A","description":"B"}"#, + ) + .unwrap(); + assert_eq!( + (p.display_name.as_deref(), p.description.as_deref()), + (Some("A"), Some("B")) + ); + assert!(parse_profile("u-1", br"{}").is_err()); + assert!(parse_profile("u-1", br#"{"name":"u-2","display_name":"A"}"#).is_err()); + assert!(parse_profile("u-1", br#"{"displayName":"A"}"#).is_err()); + let long = format!( + r#"{{"display_name":"{}"}}"#, + "x".repeat(DISPLAY_NAME_MAX_CHARS + 1) + ); + assert!(parse_profile("u-1", long.as_bytes()).is_err()); + } + + #[test] + fn entries_omit_absent_fields() { + let e = OwnerEntry { + name: "u-1".into(), + profile: OwnerProfile { + display_name: Some("A".into()), + description: None, + }, + }; + assert_eq!( + serde_json::to_value(&e).unwrap(), + serde_json::json!({"name": "u-1", "display_name": "A"}) + ); + let r = RepoEntry { + name: "r".into(), + description: None, + }; + assert_eq!( + serde_json::to_value(&r).unwrap(), + serde_json::json!({"name": "r"}) + ); + } + + #[tokio::test] + async fn store_reads_are_lenient_and_absent_is_default() { + let store: DynStore = std::sync::Arc::new(walgit_store::memory::MemoryStore::new()); + let id = RepoId::new("o", "r").unwrap(); + assert_eq!( + load_description(&store, &id).await.unwrap(), + RepoDescription::default() + ); + // A future binary's extra key is ignored, not an error. + store + .put( + &keys::description_key("o", "r"), + PutBody::from(br#"{"description":"x","since":"v2"}"#.to_vec()), + PutMode::Overwrite.into(), + ) + .await + .unwrap(); + assert_eq!( + load_description(&store, &id) + .await + .unwrap() + .description + .as_deref(), + Some("x") + ); + // Garbage and oversized objects read as absent. + store + .put( + &keys::owner_profile_key("o"), + PutBody::from(b"{".to_vec()), + PutMode::Overwrite.into(), + ) + .await + .unwrap(); + assert!(load_profile(&store, "o").await.unwrap().is_empty()); + store + .put( + &keys::owner_profile_key("p"), + PutBody::from(vec![b' '; MAX_DOCUMENT_BYTES + 1]), + PutMode::Overwrite.into(), + ) + .await + .unwrap(); + assert!(load_profile(&store, "p").await.unwrap().is_empty()); + clear_profile(&store, "nobody").await.unwrap(); + let rows = owner_details(&store, vec!["o".into(), "q".into()]) + .await + .unwrap(); + assert_eq!( + rows.iter().map(|r| r.name.as_str()).collect::<Vec<_>>(), + ["o", "q"] + ); + } +} diff --git a/crates/walgit-server/src/policy.rs b/crates/walgit-server/src/policy.rs index 33a3dd74..82308a5f 100644 --- a/crates/walgit-server/src/policy.rs +++ b/crates/walgit-server/src/policy.rs @@ -700,7 +700,7 @@ pub async fn http_delete( Ok((StatusCode::NO_CONTENT, "").into_response()) } -async fn ensure_repo(st: &AppState, route: &RepoRoute) -> Result<(), ApiError> { +pub(crate) async fn ensure_repo(st: &AppState, route: &RepoRoute) -> Result<(), ApiError> { st.registry.open(&route.id).await.map(|_| ()).map_err(|e| { if matches!(e, walgit_wal::WalError::NotFound) { ApiError::NotFound(format!("{}", route.id)) diff --git a/crates/walgit-server/src/settings.rs b/crates/walgit-server/src/settings.rs index 0c6da1e9..2a90af02 100644 --- a/crates/walgit-server/src/settings.rs +++ b/crates/walgit-server/src/settings.rs @@ -176,7 +176,7 @@ async fn publish( } } -fn percent_decode(v: &str) -> String { +pub(crate) fn percent_decode(v: &str) -> String { let mut out = Vec::with_capacity(v.len()); let b = v.as_bytes(); let mut i = 0; diff --git a/crates/walgit-server/src/web/api.rs b/crates/walgit-server/src/web/api.rs index 2d26704b..8853db7b 100644 --- a/crates/walgit-server/src/web/api.rs +++ b/crates/walgit-server/src/web/api.rs @@ -414,6 +414,15 @@ pub(crate) fn etag_for(sha: &str) -> String { pub(crate) fn json_swr<T: Serialize>(value: &T, etag: Option<&str>) -> Rendered { Rendered::json(json_bytes(value), SWR, etag.map(str::to_string)) } +/// SWR JSON whose `ETag` is a digest of the body: for answers with no natural version (a +/// control object outside the WAL, a listing with detail). A revalidation that finds nothing +/// changed ends in a `304` — it saves the transfer, not the server's store reads. +pub(crate) fn json_swr_digest<T: Serialize>(value: &T) -> Rendered { + use sha1::Digest; + let body = json_bytes(value); + let etag = etag_for(&hex::encode(sha1::Sha1::digest(&body))); + Rendered::json(body, SWR, Some(etag)) +} fn json_bytes<T: Serialize>(value: &T) -> bytes::Bytes { bytes::Bytes::from(serde_json::to_vec(value).unwrap_or_default()) } @@ -470,31 +479,75 @@ async fn instance_info( // ---- owners ------------------------------------------------------------------ +/// `?detail=1` on the owner listings (`web/API.md`). Plain calls keep their `string[]` +/// shape; detail answers objects with the human-readable metadata (`crate::metadata`). +#[derive(serde::Deserialize, Default)] +pub(crate) struct ListQuery { + detail: Option<String>, +} + +impl ListQuery { + fn detail(&self) -> Result<bool, ApiError> { + match self.detail.as_deref() { + None | Some("0" | "false") => Ok(false), + Some("1" | "true") => Ok(true), + Some(other) => Err(ApiError::BadRequest(format!( + "detail={other}: expected 1 or 0" + ))), + } + } +} + +/// `GET /api/v1/owners[?detail=1]` → `["o", …]`, or `[{name, display_name?, description?}]`: +/// the listing (`Registry::list`, cached `LIST_TTL`) then one profile GET per owner, +/// `metadata::DETAIL_CONCURRENCY` in flight. The detail answer carries a body-digest `ETag`. pub(crate) async fn owners( State(st): State<Arc<AppState>>, headers: HeaderMap, + Query(q): Query<ListQuery>, ) -> Result<Response, ApiError> { st.auth.require_read(&headers).await.map_err(auth_err)?; + let detail = q.detail()?; let repos = st.registry.list().await.map_err(internal)?; let mut out: Vec<String> = repos.into_iter().map(|r| r.owner().to_string()).collect(); out.sort(); out.dedup(); + if detail { + let rows = crate::metadata::owner_details(&st.store, out) + .await + .map_err(internal)?; + return Ok(json_swr_digest(&rows).into_response(&headers)); + } Ok(json_swr(&out, None).into_response(&headers)) } + +/// `GET /api/v1/owners/{owner}/repos[?detail=1]` → `["r", …]`, or `[{name, description?}]` +/// (one description GET per repository, as above). `[]` for an unknown owner either way. pub(crate) async fn owner_repos( State(st): State<Arc<AppState>>, headers: HeaderMap, Path(owner): Path<String>, + Query(q): Query<ListQuery>, ) -> Result<Response, ApiError> { st.auth.require_read(&headers).await.map_err(auth_err)?; - let repos = st.registry.list().await.map_err(internal)?; - let mut out: Vec<String> = repos + let detail = q.detail()?; + let mut repos: Vec<walgit_git::RepoId> = st + .registry + .list() + .await + .map_err(internal)? .into_iter() .filter(|r| r.owner() == owner) - .map(|r| r.name().to_string()) .collect(); - out.sort(); - out.dedup(); + repos.sort_by(|a, b| a.name().cmp(b.name())); + repos.dedup(); + if detail { + let rows = crate::metadata::repo_details(&st.store, repos) + .await + .map_err(internal)?; + return Ok(json_swr_digest(&rows).into_response(&headers)); + } + let out: Vec<String> = repos.into_iter().map(|r| r.name().to_string()).collect(); Ok(json_swr(&out, None).into_response(&headers)) } diff --git a/crates/walgit-server/src/web/v1.rs b/crates/walgit-server/src/web/v1.rs index d297dec3..134ae33b 100644 --- a/crates/walgit-server/src/web/v1.rs +++ b/crates/walgit-server/src/web/v1.rs @@ -9,7 +9,8 @@ //! Same handlers; lanes differ by credential handling and CORS, never by a //! rewrite. Non-repo: `/api/v1` (discovery), `/api/v1/me`, `/api/v1/authenticate` //! (+ the `/api-browser/v1/me|authenticate` pair the SDK's popup uses), -//! `/api/v1/owners*`. The SDK (`repos.js`, `web/sdk/`) maps this one to one. +//! `/api[-browser]/v1/owners*` (listings, detail listings, owner profiles). The SDK +//! (`repos.js`, `web/sdk/`) maps this one to one. use std::sync::Arc; @@ -42,13 +43,22 @@ pub fn router(state: Arc<AppState>) -> Router { .route(&format!("{API_V1}/me"), get(me)) .route(&format!("{API_V1}/authenticate"), get(authenticate)) .route(&format!("{API_BROWSER}/v1/me"), get(me)) - .route(&format!("{API_BROWSER}/v1/authenticate"), get(authenticate)) - .route(&format!("{API_V1}/owners"), get(crate::web::api::owners)) - .route( - &format!("{API_V1}/owners/{{owner}}/repos"), - get(crate::web::api::owner_repos), - ); - // Repo admin under both lanes: summary/create/delete, policy, settings. + .route(&format!("{API_BROWSER}/v1/authenticate"), get(authenticate)); + // Owners on both non-repo lanes: the SDK addresses `owners*` as `/api/v1/…` (bearer, + // same-origin) or `/api-browser/v1/…` (another origin, `credentials: "include"`). + for base in [API_V1.to_string(), format!("{API_BROWSER}/v1")] { + r = r + .route(&format!("{base}/owners"), get(crate::web::api::owners)) + .route( + &format!("{base}/owners/{{owner}}"), + get(owner_profile).put(owner_profile).delete(owner_profile), + ) + .route( + &format!("{base}/owners/{{owner}}/repos"), + get(crate::web::api::owner_repos), + ); + } + // Repo admin under both lanes: summary/create/delete, policy, description, settings. for base in crate::web::api::REPO_API_BASES { r = r .route(base, get(repo_summary).put(repo_admin).delete(repo_admin)) @@ -57,6 +67,10 @@ pub fn router(state: Arc<AppState>) -> Router { get(repo_admin).put(repo_admin).delete(repo_admin), ) .route(&format!("{base}/policy/{{sub}}"), post(repo_admin_sub)) + .route( + &format!("{base}/description"), + get(repo_admin).put(repo_admin).delete(repo_admin), + ) .route( &format!("{base}/settings"), get(repo_admin).put(repo_admin).delete(repo_admin), @@ -225,8 +239,9 @@ async fn discovery(State(st): State<Arc<AppState>>, headers: HeaderMap) -> Respo }, endpoints: vec![ "GET /api/v1/me", - "GET /api/v1/owners", - "GET /api/v1/owners/{owner}/repos", + "GET /api/v1/owners[?detail=1]", + "GET|PUT|DELETE /api/v1/owners/{owner} (profile: display_name, description)", + "GET /api/v1/owners/{owner}/repos[?detail=1]", "GET /api/v1/authenticate (also /api-browser/v1/me|authenticate for the browser lane)", "-- repository routes live under the repository (D27): /{owner}/{repo}/api/… (bearer/session) and /{owner}/{repo}/api-browser/… (browser lane) --", "GET|PUT|DELETE /{owner}/{repo}/api", @@ -241,6 +256,7 @@ async fn discovery(State(st): State<Arc<AppState>>, headers: HeaderMap) -> Respo "GET /{owner}/{repo}/api/tasks[/{id}]", "GET /{owner}/{repo}/api/ops", "POST /{owner}/{repo}/api/ops/{op}", + "GET|PUT|DELETE /{owner}/{repo}/api/description", "GET|PUT|DELETE /{owner}/{repo}/api/policy", "POST /{owner}/{repo}/api/policy/validate | dry-run?last=N", "GET|PUT|DELETE /{owner}/{repo}/api/settings", @@ -366,6 +382,24 @@ async fn repo_summary( .await } +/// `GET|PUT|DELETE /api[-browser]/v1/owners/{owner}` — the owner's profile +/// (`crate::metadata`): read for GET, admin for PUT/DELETE. +async fn owner_profile( + State(st): State<Arc<AppState>>, + Path(owner): Path<String>, + req: Request<Body>, +) -> Response { + let headers = req.headers().clone(); + let result = match *req.method() { + Method::PUT => { + crate::metadata::http_put_profile(&st, &owner, &headers, req.into_body()).await + } + Method::DELETE => crate::metadata::http_delete_profile(&st, &owner, &headers).await, + _ => crate::metadata::http_get_profile(&st, &owner, &headers).await, + }; + result.unwrap_or_else(IntoResponse::into_response) +} + /// `GET|POST /{o}/{r}/api[-browser]/settings/{sub}` (effective | history | validate) and `POST …/policy/{sub}`. async fn repo_admin_sub( State(st): State<Arc<AppState>>, diff --git a/crates/walgit-server/tests/api_v1.rs b/crates/walgit-server/tests/api_v1.rs index 5708254f..8ce3391c 100644 --- a/crates/walgit-server/tests/api_v1.rs +++ b/crates/walgit-server/tests/api_v1.rs @@ -631,3 +631,391 @@ async fn policy_and_settings_writes_require_admin() -> TestResult { assert_eq!(st, 200, "{text}"); Ok(()) } + +/// Human-readable metadata (`crate::metadata`): a repository's description and an owner's +/// profile — read with read, written only with admin (also at create), strict on write, +/// absent fields omitted, and the owner listings' `?detail=1` beside unchanged plain shapes. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn descriptions_and_owner_profiles() -> TestResult { + use reqwest::Method; + async fn send( + server: &Server, + method: Method, + path: &str, + auth: &[(&str, &str)], + body: String, + ) -> anyhow::Result<(reqwest::StatusCode, String)> { + let mut r = reqwest::Client::new() + .request(method, format!("{}{path}", server.base_url)) + .header("Accept", "application/json") + .body(body); + for (k, v) in auth { + r = r.header(*k, *v); + } + let resp = r.send().await?; + let status = resp.status(); + Ok((status, resp.text().await?)) + } + async fn get_json(server: &Server, path: &str, auth: &[(&str, &str)]) -> anyhow::Result<Value> { + let (st, text, _) = req(server, Method::GET, path, auth).await?; + anyhow::ensure!(st == 200, "GET {path} -> {st}: {text}"); + Ok(serde_json::from_str(&text)?) + } + let token = |principal: &str, write: bool, admin: bool| walgit_config::StaticToken { + principal: principal.into(), + token: format!("{principal}-token"), + token_env: None, + write, + admin, + }; + let tokens = vec![ + token("reader", false, false), + token("writer", true, false), + token("admin", true, true), + ]; + let server = Server::start_with_tweak(move |c| { + c.server.auth.mode = walgit_config::AuthMode::Token; + c.server.auth.anonymous_read = false; + c.server.auth.tokens = tokens; + }) + .await?; + let reader = [("Authorization", "Bearer reader-token")]; + let writer = [("Authorization", "Bearer writer-token")]; + let admin = [("Authorization", "Bearer admin-token")]; + for repo in ["u1/alpha", "u1/beta", "u2/gamma"] { + assert_eq!( + req(&server, Method::PUT, &format!("/{repo}/api"), &writer) + .await? + .0, + 201 + ); + } + + // ---- repository description ------------------------------------------------------- + assert_eq!( + req(&server, Method::GET, "/u1/alpha/api/description", &[]) + .await? + .0, + 401, + "reads need a credential" + ); + assert_eq!( + get_json(&server, "/u1/alpha/api/description", &reader).await?, + serde_json::json!({}) + ); + let doc = r#"{"description":" Alpha service "}"#.to_string(); + for who in [&reader, &writer] { + let (st, text) = send( + &server, + Method::PUT, + "/u1/alpha/api/description", + who, + doc.clone(), + ) + .await?; + assert_eq!(st, 403, "{text}"); + assert_eq!( + req(&server, Method::DELETE, "/u1/alpha/api/description", who) + .await? + .0, + 403 + ); + } + let (st, text) = send( + &server, + Method::PUT, + "/u1/alpha/api/description", + &admin, + doc.clone(), + ) + .await?; + assert_eq!(st, 204, "{text}"); + let (st, text, h) = req( + &server, + Method::GET, + "/u1/alpha/api-browser/description", + &reader, + ) + .await?; + assert_eq!(st, 200, "browser lane: {text}"); + assert_eq!( + serde_json::from_str::<Value>(&text)?, + serde_json::json!({"description": "Alpha service"}) + ); + let etag = hdr(&h, "etag"); + assert!(etag.starts_with('"'), "digest ETag: {etag:?}"); + assert!(hdr(&h, "cache-control").contains("stale-while-revalidate")); + let revalidate = [reader[0], ("If-None-Match", etag.as_str())]; + assert_eq!( + req( + &server, + Method::GET, + "/u1/alpha/api/description", + &revalidate + ) + .await? + .0, + 304 + ); + + let too_long = format!(r#"{{"description":"{}"}}"#, "x".repeat(513)); + for bad in [ + too_long, + r#"{"description":"two\nlines"}"#.to_string(), + r#"{"description":""}"#.to_string(), + r#"{"descripton":"typo"}"#.to_string(), + r#"{"description":5}"#.to_string(), + ] { + let (st, text) = send( + &server, + Method::PUT, + "/u1/alpha/api/description", + &admin, + bad.clone(), + ) + .await?; + assert_eq!(st, 400, "{bad}: {text}"); + } + let (st, _) = send( + &server, + Method::PUT, + "/u1/alpha/api/description", + &admin, + " ".repeat(9000), + ) + .await?; + assert_eq!(st, 413, "body above the document bound"); + let (st, _) = send( + &server, + Method::PUT, + "/u1/nope/api/description", + &admin, + doc.clone(), + ) + .await?; + assert_eq!( + st, 404, + "no description for a repository that does not exist" + ); + assert_eq!( + req(&server, Method::GET, "/u1/nope/api/description", &reader) + .await? + .0, + 404 + ); + + // ---- create with a description: admin only, validated before anything exists --------- + assert_eq!( + req( + &server, + Method::PUT, + "/u2/delta/api?description=Delta%20svc", + &writer + ) + .await? + .0, + 403, + "write permission creates, but does not label" + ); + assert_eq!( + req(&server, Method::GET, "/u2/delta/api", &reader).await?.0, + 404, + "refused create made nothing" + ); + assert_eq!( + req( + &server, + Method::PUT, + "/u2/delta/api?object_format=sha1&description=Delta%20svc", + &admin + ) + .await? + .0, + 201 + ); + assert_eq!( + get_json(&server, "/u2/delta/api/description", &reader).await?, + serde_json::json!({"description": "Delta svc"}) + ); + assert_eq!( + req( + &server, + Method::PUT, + "/u2/eps/api?description=a%0Ab", + &admin + ) + .await? + .0, + 400 + ); + assert_eq!( + req(&server, Method::GET, "/u2/eps/api", &reader).await?.0, + 404, + "invalid label, no repo" + ); + + // ---- owner profile ------------------------------------------------------------------- + assert_eq!( + get_json(&server, "/api/v1/owners/u1", &reader).await?, + serde_json::json!({"name": "u1"}) + ); + let profile = r#"{"display_name":"Team One","description":"Owns alpha and beta"}"#.to_string(); + for who in [&reader, &writer] { + let (st, _) = send( + &server, + Method::PUT, + "/api/v1/owners/u1", + who, + profile.clone(), + ) + .await?; + assert_eq!(st, 403); + assert_eq!( + req(&server, Method::DELETE, "/api/v1/owners/u1", who) + .await? + .0, + 403 + ); + } + let (st, text) = send( + &server, + Method::PUT, + "/api/v1/owners/u1", + &admin, + profile.clone(), + ) + .await?; + assert_eq!(st, 204, "{text}"); + let want = serde_json::json!({"name": "u1", "display_name": "Team One", "description": "Owns alpha and beta"}); + assert_eq!(get_json(&server, "/api/v1/owners/u1", &reader).await?, want); + assert_eq!( + get_json(&server, "/api-browser/v1/owners/u1", &reader).await?, + want, + "browser lane" + ); + for bad in [ + r"{}", + r#"{"displayName":"x"}"#, + r#"{"name":"u2","display_name":"x"}"#, + ] { + let (st, text) = send( + &server, + Method::PUT, + "/api/v1/owners/u1", + &admin, + bad.to_string(), + ) + .await?; + assert_eq!(st, 400, "{bad}: {text}"); + } + assert_eq!( + req(&server, Method::GET, "/api/v1/owners/.hidden", &reader) + .await? + .0, + 400 + ); + // A profile does not create an owner: written, but not listed until a repository exists. + let (st, _) = send( + &server, + Method::PUT, + "/api/v1/owners/ghost", + &admin, + r#"{"display_name":"Ghost"}"#.into(), + ) + .await?; + assert_eq!(st, 204); + + // ---- listings: plain shapes unchanged, detail beside them ------------------------------ + assert_eq!( + get_json(&server, "/api/v1/owners", &reader).await?, + serde_json::json!(["u1", "u2"]) + ); + let (st, text, h) = req(&server, Method::GET, "/api/v1/owners?detail=1", &reader).await?; + assert_eq!(st, 200, "{text}"); + assert_eq!( + serde_json::from_str::<Value>(&text)?, + serde_json::json!([ + {"name": "u1", "display_name": "Team One", "description": "Owns alpha and beta"}, + {"name": "u2"}, + ]) + ); + let etag = hdr(&h, "etag"); + let revalidate = [reader[0], ("If-None-Match", etag.as_str())]; + assert_eq!( + req(&server, Method::GET, "/api/v1/owners?detail=1", &revalidate) + .await? + .0, + 304 + ); + assert_eq!( + get_json(&server, "/api-browser/v1/owners?detail=1", &reader) + .await? + .as_array() + .map(Vec::len), + Some(2), + "browser lane" + ); + assert_eq!( + get_json(&server, "/api/v1/owners/u1/repos", &reader).await?, + serde_json::json!(["alpha", "beta"]) + ); + assert_eq!( + get_json(&server, "/api/v1/owners/u1/repos?detail=1", &reader).await?, + serde_json::json!([{"name": "alpha", "description": "Alpha service"}, {"name": "beta"}]) + ); + assert_eq!( + get_json(&server, "/api/v1/owners/nobody/repos?detail=1", &reader).await?, + serde_json::json!([]) + ); + assert_eq!( + req(&server, Method::GET, "/api/v1/owners?detail=yes", &reader) + .await? + .0, + 400 + ); + + // ---- clear, and a deleted repository takes its description with it --------------------- + assert_eq!( + req(&server, Method::DELETE, "/api/v1/owners/u1", &admin) + .await? + .0, + 204 + ); + assert_eq!( + get_json(&server, "/api/v1/owners/u1", &reader).await?, + serde_json::json!({"name": "u1"}) + ); + assert_eq!( + req(&server, Method::DELETE, "/api/v1/owners/u1", &admin) + .await? + .0, + 204, + "idempotent" + ); + assert_eq!( + req(&server, Method::DELETE, "/u2/delta/api/description", &admin) + .await? + .0, + 204 + ); + assert_eq!( + get_json(&server, "/u2/delta/api/description", &reader).await?, + serde_json::json!({}) + ); + assert_eq!( + req(&server, Method::DELETE, "/u1/alpha/api", &admin) + .await? + .0, + 204 + ); + assert_eq!( + req(&server, Method::PUT, "/u1/alpha/api", &writer).await?.0, + 201 + ); + assert_eq!( + get_json(&server, "/u1/alpha/api/description", &reader).await?, + serde_json::json!({}), + "repository deletion removes every object under its prefix, the description too" + ); + Ok(()) +} diff --git a/docs/CONTRACT.md b/docs/CONTRACT.md index 2aa73020..826a0201 100644 --- a/docs/CONTRACT.md +++ b/docs/CONTRACT.md @@ -33,7 +33,7 @@ Read `AGENTS.md` first (design §1–§2, decisions §3; the original layout/pha pub struct RepoId { owner: String, name: String } // FromStr("owner/name" | "owner/name.git"), Display "owner/name". Validation: each part ASCII [A-Za-z0-9._-], // no leading '.', not "..", 1..=100 chars. fn owner(), name(), store_prefix() (walgit_proto::keys::repo_prefix), -// local_dir(root:&Path)->PathBuf (= root/owner/name.git). +// local_dir(root:&Path)->PathBuf (= root/owner/name.git). validate_owner(&str) applies the owner rule alone. pub enum ObjectFormat { Sha1, Sha256 } // From<walgit_config::ObjectFormat>, <-> gix_hash::Kind, as_str() /// Bare git repo on local disk in standard layout (objects/pack/*.{pack,idx}, loose refs + packed-refs, HEAD, diff --git a/docs/ROUNDTRIPS.md b/docs/ROUNDTRIPS.md index 447af901..c05de48d 100644 --- a/docs/ROUNDTRIPS.md +++ b/docs/ROUNDTRIPS.md @@ -56,6 +56,7 @@ right shape. This document is the thinking tool; apply it to every protocol chan | Lease acquire | 1 GET → 1 CAS put (or 1 Create when absent) | 2 | `coord.rs::try_acquire` | | Publish, local commit (2026-08-23) | unchanged in round trips: after the manifest CAS the ref txns are applied to the local copy **before** the new manifest version is advertised, both under `sync_mutex` (the refs phase of every sync); the reverse order let a reader cache the old refs under the new version, and without the lock a concurrent sync replayed the same entry (two `update-ref`, a lock collision). A landed CAS is answered `ok` whatever the local apply does — the next sync replays (one conditional GET that then returns 200, no extra write). | 0 extra | `publish.rs::process_batch` | | Repository listing (`/api/v1/owners*`, `/services/api/owners*`, maintainer/bridge passes) | 0 within `LIST_TTL` (30 s, per instance); else delimited `repos/` → (delimited `repos/<o>/` ∥ owners) → (HEAD `manifest.pb` ∥ repos): 3 rounds | 1 + owners + repos | `registry.rs::list` | +| Human-readable metadata (2026-09-30): `…/api/description`, `/api/v1/owners/{owner}` (`metadata.rs`) | GET: 1 object GET (a 404 is the "none" answer), concurrent with the repository-exists check; PUT/DELETE: repo check → 1 overwrite/delete. `?detail=1` on the owner listings: the listing row above → one GET per listed owner resp. repository, 32 in flight, order kept (`buffered`) | detail: 1 + owners (resp. repos) GETs, depth ⌈n/32⌉ after the listing; plain listings, git paths, push and every sync: 0 extra. Not on the WAL, no manifest write, no CAS'd object, no in-process cache (principle IV: every read revalidates; `policy.json` sets the precedent). Acceptable because it is opt-in per call, bounded by the listing it decorates, cheap per item (small objects, mostly 404s) and answered with a body-digest ETag so a browser revalidation that finds nothing changed is a 304 without a transfer | `metadata.rs`, `web/api.rs::owners` | | Bundle removal (2026-09-11) | v2 capabilities and narrated fetch: removed optional list GET (1 → 0 extra); maintenance no longer reads/CASes a bundle list; direct import no longer composes a wrapper or reads/CASes a bundle list | no new store requests; checkpoint and push budgets unchanged | `smart.rs`, `maintain.rs`, `import_direct.rs` | | Canonical checkpoint publication | unchanged: freshness → content-addressed refs PUT ∥ attempt-specific metadata PUT → manifest CAS | 4 healthy requests; equality verification GET only after immutable Create conflict | `checkpoint.rs`, `snapshots.rs` | | Orphan log slot (failure path only) | +1 fresh manifest GET, +HEAD per probe, +Create at next seq | — | `publish.rs::claim_log_slot` | diff --git a/web/API.md b/web/API.md index 147dee43..932e74b8 100644 --- a/web/API.md +++ b/web/API.md @@ -13,7 +13,7 @@ walgit internals except the optional `overview`/`ops`/`tasks` endpoints. ``` https://git.example.com/{owner}/{repo}/api/… bearer lane a bearer token or the same-origin session cookie https://git.example.com/{owner}/{repo}/api-browser/… browser lane credentials: "include" (other origins; the cross-origin cookie lane) -https://git.example.com/api/v1 non-repo discovery, me, authenticate, owners (+ /api-browser/v1/me|authenticate for the browser lane) +https://git.example.com/api/v1 non-repo discovery, me, authenticate, owners (+ /api-browser/v1/me|authenticate|owners* for the browser lane) https://git.example.com/repos.js the SDK window.repos — permanent URL on every host ``` Lanes differ by **credential handling** and CORS (D27): the bearer is a walgit access token (`/_auth/tokens`), a @@ -31,7 +31,7 @@ resolve-then-fetch-by-sha flow of §2a). | Lane | Path | Who | Credentials | |---|---|---|---| | **bearer / same-origin** | `/{owner}/{repo}/api/…` (+ `/api/v1/…` for non-repo) | git tooling, CI, agents, scripts, and the bundled same-origin UI | `Authorization: Bearer <token>`, or the same-origin session cookie | -| **browser** | `/{owner}/{repo}/api-browser/…` (+ `/api-browser/v1/me\|authenticate`) | other allowed origins loading `repos.js` | `fetch(…, {credentials: "include"})`; the session cookie from walgit's own sign-in (`/_auth/login`) | +| **browser** | `/{owner}/{repo}/api-browser/…` (+ `/api-browser/v1/me\|authenticate\|owners*`) | other allowed origins loading `repos.js` | `fetch(…, {credentials: "include"})`; the session cookie from walgit's own sign-in (`/_auth/login`) | **The repository prefix comes first on every lane** — it is the only routing key (D26: one edge rule sends `/acme/monorepo*` to its host). Both lanes hit the same handlers; they differ by credential handling and CORS, @@ -100,7 +100,7 @@ falls in the first: | Class | URLs | Headers | |---|---|---| | **Sha-addressed, immutable** | `tree/{sha}/…`, `blob/{sha}/…`, `commits?ref={sha}`, `commit/{sha}` where `{sha}` is a full 40-hex sha | `Cache-Control: private, max-age=31536000, immutable`. Content can never change; browsers never re-ask. | -| **Ref-dependent** | `owners`, `owners/{o}`, `refs`, `refs/branches`, `refs/tags`, `resolve/…`, and any tree/blob/commits/commit addressed by a *name* | `Cache-Control: private, max-age=0, stale-while-revalidate=60` and, where there is a natural one, `ETag: "<resolved sha>"` with `If-None-Match` → `304`. | +| **Ref-dependent** | `owners`, `owners/{o}`, `owners/{o}/repos` (with or without `?detail=1`), `description`, `refs`, `refs/branches`, `refs/tags`, `resolve/…`, and any tree/blob/commits/commit addressed by a *name* | `Cache-Control: private, max-age=0, stale-while-revalidate=60` and, where there is a natural one, `ETag: "<resolved sha>"` with `If-None-Match` → `304`. The metadata answers (`?detail=1` listings, `owners/{o}`, `description`) have no natural version and carry a digest of their body as `ETag`. | Flow per navigation: one ref-dependent call (`resolve`, SWR — paints instantly from cache, revalidates in the background; a `304` costs the @@ -218,7 +218,7 @@ Responses echo `ref`, `sha` and `path` so the client never re-splits. ## 4. Endpoints -### `GET /api/v1/owners` +### `GET /api/v1/owners[?detail=1]` Top-level namespaces. @@ -230,7 +230,19 @@ Sorted, `[]` if none. Must come from the authoritative repo list (walgit lists the object store, not local disk, so a cold node sees everything). Cache: SWR (§2a). -### `GET /api/v1/owners/{owner}/repos` +`?detail=1` answers the same owners, same order, as objects carrying each owner's profile +(below); a field is absent when unset, never `null`: + +```json +[{ "name": "3f2a9c1e", "display_name": "Payments", "description": "Ledger and settlement" }, { "name": "jane" }] +``` + +Owners stay **implicit** — derived from repository ids; a profile does not create one, and a +profile whose owner has no repository is not listed. Cost: one small store read per listed owner, +issued concurrently (`docs/ROUNDTRIPS.md`); the plain form costs nothing extra. Cache: SWR + +`ETag` (body digest). + +### `GET /api/v1/owners/{owner}/repos[?detail=1]` Repositories under one owner, short names only. @@ -239,6 +251,27 @@ Repositories under one owner, short names only. ``` Sorted, `[]` for an unknown/empty owner (200, not 404). Cache: SWR. +`?detail=1` → `[{ "name": "hello", "description": "…" }, { "name": "walgit" }]` (one store read +per repository; SWR + body-digest `ETag`). + +### `GET|PUT|DELETE /api/v1/owners/{owner}` + +The owner's **profile**: the human-readable name for an owner whose id is opaque. + +```json +{ "name": "3f2a9c1e", "display_name": "Payments", "description": "Ledger and settlement" } +``` + +`GET` (read) answers 200 for any valid owner name, with or without repositories or a profile +(`{"name": …}` alone when none is set); `400` for a name no repository could have. `PUT` (admin) +body `{"display_name"?, "description"?}` — at least one; `name` is accepted only when it equals the +path (so a client can `PUT` back what it got; the id never changes) → `204`. `DELETE` (admin) → +`204`. Each field is one line of plain text (no control characters, trimmed, non-empty): +`display_name` ≤ 100, `description` ≤ 512 characters; unknown keys and non-strings are `400`, +a body above 8 KiB `413`. Stored at `owners/<owner>/profile.json` in the bucket (not on the WAL). +Writing a profile for an owner without repositories is allowed (an operator can name an owner +before its first repository); it is listed once a repository exists. Cache: SWR + body-digest +`ETag`. Also on the browser lane (`/api-browser/v1/owners/{owner}`, like every `owners*` route). ### `GET /api/v1/me` @@ -262,7 +295,19 @@ Ref-level summary: head (`null` when unborn), O(1) ref counts from the ref index, URLs. `404` for an unknown repo. Cache: SWR + `ETag: "<head sha>"`. `PUT` creates the repository (write permission; `201`/`200`), `DELETE` removes it (admin permission) — the same handlers as `PUT|DELETE /{owner}/{repo}`. -`GET|PUT|DELETE …/policy` is the push policy document (`docs/POLICY.md`). +`PUT …?object_format=sha1|sha256&description=…` (percent-encoded): `description` is validated +before anything is created (`400`, nothing created) and needs **admin**, like every description +write — create alone stays a write operation. `GET|PUT|DELETE …/policy` is the push policy +document (`docs/POLICY.md`). + +`GET|PUT|DELETE /{o}/{r}/api/description` is the repository's **description** +(`repos/<o>/<r>/description.json`, not on the WAL, never read by git): `GET` (read) → +`{"description": "…"}`, or `{}` when none is set; `404` for an unknown repository; SWR + +body-digest `ETag`. `PUT` (admin) body `{"description": "…"}` → `204`; `DELETE` (admin) → `204`. +The same rules as the owner profile's fields: one line of plain text, ≤ 512 characters, unknown +keys `400`, body > 8 KiB `413`. Deleting the repository deletes it. Git's own `description` file +is not written: the local repository is a disposable per-instance cache and nothing git serves +reads it. CLI: `walgit repo describe <owner/name> [--set TEXT | --clear]`. `GET|PUT|DELETE /{o}/{r}/api/settings` (D24, 2026-08-21) is the repository's **settings in the WAL**: a TOML document restricted to `[refs]`, `[packfile_uri]`, `[maintenance]`, `[packs]` and `[upstream]`, merged over the @@ -504,7 +549,8 @@ redelivers). Never cached, never served to the SPA. after a push is acknowledged, the next API call (any node) reflects it. - Writes on the JSON surface need a token with the matching permission: write for `PUT /{o}/{r}/api` (create) and `POST …/ops/{op}`, admin for - `DELETE /{o}/{r}/api`, `PUT|DELETE …/policy` and `PUT|DELETE …/settings` + `DELETE /{o}/{r}/api`, `PUT|DELETE …/policy`, `PUT|DELETE …/description` (and a create + carrying `?description=`), `PUT|DELETE /api/v1/owners/{o}` and `PUT|DELETE …/settings` (D24: write is push, not admin). Content moves over git (`git-receive-pack`) and LFS, never through JSON. @@ -515,6 +561,10 @@ GET /api/v1 → 200 {name, version:1, base, GET /api/v1/me → 200 {principal,write,anonymous} | 401; no-store GET /api/v1/owners → 200 [..] ([] when empty) GET /api/v1/owners/nobody/repos → 200 [] +GET /api/v1/owners?detail=1 → 200 [{name, display_name?, description?}]; SWR + ETag +GET /api/v1/owners/o/repos?detail=1 → 200 [{name, description?}]; SWR + ETag +GET /api/v1/owners/o → 200 {name, display_name?, description?}; PUT|DELETE admin → 204 +GET /o/r/api/description → 200 {description?}; SWR + ETag; PUT|DELETE admin → 204 GET /o/r/api → 200 {owner,name,full_name,head,branches,tags,clone_url,html_url,api_url}; SWR + ETag "<head sha>" GET /o/r/api-browser/refs → same handlers (browser lane) OPTIONS /api/v1/… (Origin ∈ cors_origins) → 204 + Access-Control-Allow-{Origin,Credentials,Methods,Headers} diff --git a/web/sdk/README.md b/web/sdk/README.md index cd8b727f..3c14fbba 100644 --- a/web/sdk/README.md +++ b/web/sdk/README.md @@ -43,11 +43,15 @@ const repos = createClient({ base: "https://git.example.com", token: process.env ```ts repos.me() → { principal, write, anonymous } repos.owners.list() → ["acme", …] +repos.owners.listDetail() → [{ name, display_name?, description? }, …] (one store read per owner) repos.owners.repos("acme") → ["monorepo", …] +repos.owners.reposDetail("acme") → [{ name, description? }, …] (one store read per repo) +repos.owners.profile.get("acme") / .put("acme", { display_name?, description? }) / .delete("acme") + → the owner's human-readable profile (writes: admin) repos.repo("acme/monorepo") → RepoClient (no request) r.get() → { owner, name, full_name, head, branches, tags, clone_url, html_url, api_url } -r.create() → write permission +r.create({ description? }) → write permission (admin with a description) r.delete() → admin permission r.refs() → { head: {name, sha} | null } r.branches({ prefix, q, after, n }) → { refs: [{name, sha}], more } (one page; tags likewise) @@ -62,6 +66,7 @@ r.commit(sha) → { commit, stats, patch } r.overview() → WAL overview (walgit-specific) r.tasks() / r.task(id, onEvent?) → what the answering instance is doing; attach to a task stream r.ops.list() / r.ops.run(op, params, onEvent) +r.description.get() / .put(text) / .delete() → { description? }: one line of plain text (writes: admin) r.policy.get() / .put(doc) / .delete() → push policy (docs/POLICY.md) r.policy.validate(doc) / .dryRun(doc, last) → validate or replay a policy against recent pushes r.settings.get() / .put(toml, message) / .delete() diff --git a/web/sdk/repos.ts b/web/sdk/repos.ts index 777800e4..ca4b2a96 100644 --- a/web/sdk/repos.ts +++ b/web/sdk/repos.ts @@ -201,6 +201,23 @@ export type Overview = Record<string, unknown> & { health: { status: "ok" | "degraded" | "error"; issues: string[]; deep: string }; ops: { available: OpSpec[]; recent: OpRecord[] }; }; +/** `GET /api/v1/owners/{owner}` and one row of `owners.listDetail()`: absent fields are omitted. */ +export interface OwnerProfile { + /** The owner id (the path segment). */ + name: string; + display_name?: string; + description?: string; +} +/** One row of `owners.reposDetail(owner)`. */ +export interface RepoDetail { + /** Short repository name. */ + name: string; + description?: string; +} +/** `GET /{owner}/{repo}/api/description`: `{}` when none is set. */ +export interface RepoDescription { + description?: string; +} /** One `/policy` document (docs/POLICY.md). */ export type Policy = Record<string, unknown>; @@ -395,8 +412,26 @@ export class ReposClient { readonly owners = { /** Top-level namespaces. */ list: (opts?: CallOptions) => this.json<string[]>("owners", opts), + /** Namespaces with their profiles (`?detail=1`: one store read per owner on the server). */ + listDetail: (opts?: CallOptions) => this.json<OwnerProfile[]>("owners?detail=1", opts), /** Repositories under one owner (short names). */ repos: (owner: string, opts?: CallOptions) => this.json<string[]>(`owners/${enc(owner)}/repos`, opts), + /** Repositories under one owner with their descriptions (`?detail=1`). */ + reposDetail: (owner: string, opts?: CallOptions) => this.json<RepoDetail[]>(`owners/${enc(owner)}/repos?detail=1`, opts), + /** An owner's display name and description (read; `put`/`delete` need admin). */ + profile: { + get: (owner: string, opts?: CallOptions) => this.json<OwnerProfile>(`owners/${enc(owner)}`, opts), + put: async (owner: string, profile: { display_name?: string; description?: string }, opts?: CallOptions): Promise<void> => { + await this.json<unknown>(`owners/${enc(owner)}`, opts, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(profile), + }); + }, + delete: async (owner: string, opts?: CallOptions): Promise<void> => { + await this.json<unknown>(`owners/${enc(owner)}`, opts, { method: "DELETE" }); + }, + }, }; /** A handle on `owner/name` (no request is made). */ @@ -599,9 +634,9 @@ export class RepoClient { get(opts?: CallOptions) { return this.client.json<RepoSummary>(this.p, opts); } - /** Create the repository (write permission). */ - async create(opts?: CallOptions): Promise<void> { - await this.client.json<unknown>(this.p, opts, { method: "PUT" }); + /** Create the repository (write permission; admin when `init.description` is given). */ + async create(init: { description?: string } = {}, opts?: CallOptions): Promise<void> { + await this.client.json<unknown>(`${this.p}${qs(init)}`, opts, { method: "PUT" }); } /** Delete the repository (admin permission). Irreversible. */ async delete(opts?: CallOptions): Promise<void> { @@ -685,6 +720,22 @@ export class RepoClient { }, }; + readonly description = { + /** The human-readable description; `{}` when none is set. */ + get: (opts?: CallOptions) => this.client.json<RepoDescription>(`${this.p}/description`, opts), + /** Replace it (admin): one line of plain text, at most 512 characters. */ + put: async (description: string, opts?: CallOptions): Promise<void> => { + await this.client.json<unknown>(`${this.p}/description`, opts, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ description }), + }); + }, + delete: async (opts?: CallOptions): Promise<void> => { + await this.client.json<unknown>(`${this.p}/description`, opts, { method: "DELETE" }); + }, + }; + readonly policy = { /** The push policy document (docs/POLICY.md); missing = `{}`-equivalent allow-all. */ get: (opts?: CallOptions) => this.client.json<Policy>(`${this.p}/policy`, opts), diff --git a/web/src/api.ts b/web/src/api.ts index f3abcdc3..1a496b13 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -28,6 +28,9 @@ export type { OpEvent, RepoSummary, Me, + OwnerProfile, + RepoDetail, + RepoDescription, } from "../sdk/repos"; import type { RefInfo, OpEvent, OpSpec, OpRecord, Tasks } from "../sdk/repos"; export type { SettingsDescribe, SettingsValidation, SettingsHistory, SettingsField, Policy, PolicyValidation, PolicyDryRun, RepoSettings } from "../sdk/repos"; @@ -85,8 +88,12 @@ export const repoApi = (repo: string) => `/${enc(repo)}/api`; const enc = (s: string) => s.split("/").map(encodeURIComponent).join("/"); export const api = { - owners: () => authRedirect(client.owners.list()), - repos: (owner: string) => authRedirect(client.owners.repos(owner)), + /** Owners with their profiles (display name, description) — the home page. */ + owners: () => authRedirect(client.owners.listDetail()), + ownerProfile: (owner: string) => authRedirect(client.owners.profile.get(owner)), + /** One owner's repositories with their descriptions. */ + repos: (owner: string) => authRedirect(client.owners.reposDetail(owner)), + description: (repo: string) => authRedirect(client.repo(repo).description.get()), refs: (repo: string) => authRedirect(client.repo(repo).refs()), refList: (repo: string, kind: "branches" | "tags", q: { q?: string; prefix?: string; after?: string; n?: number } = {}) => authRedirect(kind === "branches" ? client.repo(repo).branches(q) : client.repo(repo).tags(q)), diff --git a/web/src/pages/ApiPage.tsx b/web/src/pages/ApiPage.tsx index 7db45217..3d501b10 100644 --- a/web/src/pages/ApiPage.tsx +++ b/web/src/pages/ApiPage.tsx @@ -90,8 +90,9 @@ export function ApiPage() { <tbody> <Row path="/api/v1" desc={<>Discovery: <code>{`{base, browser_base, sdk, auth, endpoints}`}</code>.</>} cache="—" /> <Row path="/api/v1/me" desc={<><code>{`{principal, write, anonymous}`}</code> — who you are on this host.</>} cache="no-store" /> - <Row path="/api/v1/owners" desc="Owners (namespaces), sorted." cache="SWR" /> - <Row path="/api/v1/owners/{owner}/repos" desc="Repository names under one owner." cache="SWR" /> + <Row path="/api/v1/owners[?detail=1]" desc={<>Owners (namespaces), sorted; <code>?detail=1</code>: <code>{`[{name,display_name?,description?}]`}</code>.</>} cache="SWR (detail: + ETag)" /> + <Row path="/api/v1/owners/{owner}" desc={<>Owner profile <code>{`{name,display_name?,description?}`}</code> (<code>GET</code> read; <code>PUT</code>/<code>DELETE</code> admin).</>} cache="SWR + ETag" /> + <Row path="/api/v1/owners/{owner}/repos[?detail=1]" desc={<>Repository names under one owner; <code>?detail=1</code>: <code>{`[{name,description?}]`}</code>.</>} cache="SWR (detail: + ETag)" /> <Row path={`/${r}/api`} desc={<>Repo summary: <code>{`{owner,name,full_name,head,branches,tags,clone_url,html_url,api_url}`}</code> (O(1) ref counts). <code>PUT</code> creates (write), <code>DELETE</code> removes (admin).</>} @@ -128,6 +129,7 @@ export function ApiPage() { desc={<><code>{`{commit,stats:[{path,additions,deletions}],patch}`}</code> — unified diff against the first parent; any revision accepted.</>} cache="full sha → immutable · else SWR + ETag" /> + <Row path={`/${r}/api/description`} desc={<><code>{`{description?}`}</code> — one line of plain text (<code>GET</code> read; <code>PUT</code>/<code>DELETE</code> admin).</>} cache="SWR + ETag" /> <Row path={`/${r}/api/policy`} desc={<>Push policy document (<code>GET</code> read; <code>PUT</code>/<code>DELETE</code> admin).</>} cache="no-store" /> <Row path={`/${r}/api/settings`} diff --git a/web/src/pages/Owners.tsx b/web/src/pages/Owners.tsx index e1d91fad..673dc13f 100644 --- a/web/src/pages/Owners.tsx +++ b/web/src/pages/Owners.tsx @@ -17,10 +17,12 @@ export function Owners() { <Box> <ul className="list"> {owners.map((o) => ( - <li key={o}> - <Link to={`/${o}`} className="strong"> - {o} + <li key={o.name}> + <Link to={`/${o.name}`} className="strong"> + {o.display_name ?? o.name} </Link> + {o.display_name && <code className="muted small id-aside">{o.name}</code>} + {o.description && <div className="muted small">{o.description}</div>} </li> ))} </ul> diff --git a/web/src/pages/RepoLayout.tsx b/web/src/pages/RepoLayout.tsx index 468fdc41..f1cf097f 100644 --- a/web/src/pages/RepoLayout.tsx +++ b/web/src/pages/RepoLayout.tsx @@ -1,6 +1,6 @@ import { Suspense, createContext, useContext, useEffect, useMemo, useRef, useState } from "react"; import { Link, NavLink, Outlet, useLocation, useParams } from "react-router-dom"; -import { api, type Refs } from "../api"; +import { api, type Refs, type RepoDescription } from "../api"; import { useData } from "../data"; import { RouteBoundary, Skeleton } from "../components/Loading"; import { CloneSetup } from "../components/CloneSetup"; @@ -83,6 +83,9 @@ export function RepoLayout() { </Link> </h1> <CloneMenu full={full} /> + <Suspense fallback={null}> + <RepoDescriptionLine full={full} /> + </Suspense> <nav className="tabs"> <NavLink to={`/${full}`} className={() => (codeActive ? "tab active" : "tab")} end> Code @@ -106,6 +109,13 @@ export function RepoLayout() { ); } +/** The repository's description under its title; nothing when unset or unreadable (the + * header must never fail on a label — the body reports real errors). */ +function RepoDescriptionLine({ full }: { full: string }) { + const d = useData(`description:${full}`, () => api.description(full).catch((): RepoDescription => ({}))); + return d.description ? <p className="repo-desc muted">{d.description}</p> : null; +} + function RepoBody({ owner, repo, full }: { owner: string; repo: string; full: string }) { const refs = useData(`refs:${full}`, () => api.refs(full)); const ctx = useMemo(() => ({ owner, name: repo, full, refs }), [owner, repo, full, refs]); diff --git a/web/src/pages/Repos.tsx b/web/src/pages/Repos.tsx index 55d516ae..0d711e5d 100644 --- a/web/src/pages/Repos.tsx +++ b/web/src/pages/Repos.tsx @@ -5,12 +5,15 @@ import { Box } from "../components/Layout"; export function Repos() { const { owner = "" } = useParams(); - const repos = useData(`repos:${owner}`, () => api.repos(owner)); + // One key for both: two `useData` calls would suspend on the first and start the second after it. + const [profile, repos] = useData(`repos:${owner}`, () => Promise.all([api.ownerProfile(owner), api.repos(owner)])); return ( <> <h1 className="page-title"> - <Link to="/">Repositories</Link> <span className="muted">/</span> {owner} + <Link to="/">Repositories</Link> <span className="muted">/</span> {profile.display_name ?? owner} + {profile.display_name && <code className="muted small id-aside">{owner}</code>} </h1> + {profile.description && <p className="muted page-lede">{profile.description}</p>} <Box> {repos.length === 0 && ( <div className="muted pad"> @@ -20,13 +23,14 @@ export function Repos() { )} <ul className="list"> {repos.map((r) => ( - <li key={r}> - <Link to={`/${owner}/${r}`} className="strong"> - {owner}/{r} + <li key={r.name}> + <Link to={`/${owner}/${r.name}`} className="strong"> + {owner}/{r.name} </Link> + {r.description && <div className="small">{r.description}</div>} <div className="muted small"> <code> - git clone {location.origin}/{owner}/{r}.git + git clone {location.origin}/{owner}/{r.name}.git </code> </div> </li> diff --git a/web/src/styles.css b/web/src/styles.css index af952c7c..6532c773 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -248,6 +248,17 @@ h2 { align-items: center; gap: 6px; } +.repo-desc { + margin: 4px 0 0; +} +/* The id beside a human name (owner display name, repo header): secondary, never hidden. */ +.id-aside { + margin-left: 8px; + font-weight: 400; +} +.page-lede { + margin: -8px 0 16px; +} .tabs { display: flex; gap: 8px;