roots: add ents-web and land the git ents serve subcommand
commit a3c2ac5
roots: add ents-web and land the git ents serve subcommand
Lands phase 7 (docs/development-plan.adoc): a second leaf crate,
sibling to git-ents, that renders every kernel and package entity as
HTML and accepts signed, CSRF-checked mutations back, plus the serve
subcommand that wires it into the local composition root. Both land
together because git ents serve cannot exist without ents-web to
wire, and ents-web’s own composition-root-injection contract can only
be demonstrated end to end once a real composition root calls it.
ents-web’s generic list/view mechanism (render.rs) walks any
#[derive(Facet)] entity’s Shape via facet-reflect, never matching on
concrete type; forge/kiln get legitimate custom pages (comment anchor
projection, toolchain recipe provenance) instead of special cases
pushed into that generic path. Signing identity is injected by the
composition root through the new SigningIdentity trait
(roots.web-agnostic, roots.web-signing) — the crate never resolves a
key itself, local or hosted; git-ents’s own commands::serve bridges
its existing Signer to that trait, so the local root signs with the
user’s own key, never a server-key indirection. Sessions (session.rs)
live only in an in-process Mutex<HashMap>, never a database, with a
per-session CSRF token required on every state-changing route
(roots.web-session). router() alone builds a complete axum Router with
no socket ever bound, so an in-process webview embedding stays
supported alongside hosted/local serving (roots.web-agnostic) — proven directly by driving every test through
tower::ServiceExt::oneshot, no socket bound anywhere in the suite.
git ents serve reuses LocalRoot’s existing seams verbatim (no second
store construction) and binds loopback only, with no --host flag to
override it — the router it drives carries no /info/refs or
git-upload-pack route at all, so git’s own smart-HTTP transport is
never exposed (roots.local).
feat: land ents-web (list/view for members, effects, redactions,
account; custom pages for toolchains, comments; generic-edit demo on
account)
feat: add SigningIdentity, the composition-root-injected signing seam
feat: add in-memory SessionStore and per-session CSRF checking
feat: land the serve subcommand in git-ents, loopback-only, reusing
LocalRoot’s existing wiring
deps: add axum, maud (already workspace deps) as ents-web’s HTTP/
template stack; facet-reflect for the generic rendering walk; tower
and http-body-util (dev-only) to drive in-process request tests;
tokio as a normal git-ents dependency for `serve’s runtime
Assisted-by: Claude:claude-sonnet-4-6
crates/cli/git-ents/src/cli.rs
@@ -112,6 +112,22 @@
#[facet(args::subcommand)]
action: HookAction,
},
+ /// Start the local web UI (`roots.local`): reuses this repository's
+ /// existing local composition root (the same loose-ref `RefStore`,
+ /// odb, null `EventSink`, and advisory gate `git ents members`,
+ /// `git ents comment`, and every other porcelain command already use)
+ /// and adds only the `ents-web` HTTP frontend, bound to loopback —
+ /// never git's own smart-HTTP transport, which this command does not
+ /// expose in any form.
+ Serve {
+ /// Port to bind on loopback (`127.0.0.1`); `0` picks any free
+ /// port. Defaults to 4880.
+ #[facet(args::named)]
+ port: Option<u16>,
+ /// Key to sign web edits with; defaults to `user.signingkey`.
+ #[facet(args::named)]
+ key: Option<PathBuf>,
+ },
}
/// `git ents members` actions.
crates/cli/git-ents/tests/cli_help.rs
@@ -42,6 +42,7 @@
"inbox",
"redact",
"hook",
+ "serve",
] {
assert!(help.contains(name), "--help must mention {name:?}:\n{help}");
}
@@ -69,3 +70,18 @@
assert!(text.contains("list"), "{text}");
assert!(text.contains("revoke"), "{text}");
}
+
+/// `git ents serve --help` documents the loopback-only, no-git-transport
+/// contract `roots.local` requires, not just a bare flag list.
+#[test]
+// @relation(roots.local, scope=function, role=Verifies)
+fn serve_help_documents_the_loopback_only_contract() {
+ let output = Command::new(common::bin_path())
+ .args(["serve", "--help"])
+ .output()
+ .expect("runs");
+ assert!(output.status.success(), "{output:?}");
+ let text = String::from_utf8(output.stdout).expect("utf8");
+ assert!(text.contains("loopback"), "{text}");
+ assert!(text.contains("port"), "{text}");
+}
crates/cli/git-ents/src/commands/mod.rs
@@ -14,6 +14,7 @@
pub mod inbox;
pub mod members;
pub mod redact;
+pub mod serve;
pub mod setup;
pub mod toolchain;
crates/cli/ents-web/src/error.rs
@@ -1,0 +1,157 @@
+//! `ents-web`'s error type: every failure a page handler can hit, rendered
+//! as an HTTP response by rendered per-page (via the `IntoResponse` impl below) rather than at the
+//! type itself — a web frontend renders failures as pages/status codes, not
+//! terminal text, so this module stays data-only (mirrors `git-ents`'s own
+//! `error.rs` shape, one variant per failure source).
+
+/// Every way a page handler in this crate can fail.
+#[derive(Debug, thiserror::Error)]
+pub enum Error {
+ /// The named entity does not exist.
+ #[error("not found: {what}")]
+ NotFound {
+ /// What was being looked up.
+ what: String,
+ },
+
+ /// A malformed request: a bad line-range, an unparsable object id, a
+ /// missing required form field.
+ #[error("invalid request: {0}")]
+ InvalidArgument(String),
+
+ /// The gate refused the proposed mutation (`gate.verdict-reason`).
+ #[error("rejected: {0}")]
+ Refused(String),
+
+ /// `receive` rejected the batch as a stale compare-and-swap.
+ #[error("rejected: {name} changed concurrently, retry")]
+ Stale {
+ /// The ref whose precondition was stale.
+ name: String,
+ },
+
+ /// A previously redacted object would have been refilled by this
+ /// mutation (`receive.redaction-ingest`).
+ #[error("refused: object {oid} was redacted and cannot be refilled")]
+ Redacted {
+ /// The redacted object id.
+ oid: gix_hash::ObjectId,
+ },
+
+ /// The request's CSRF token was missing or did not match the session's
+ /// (`roots.web-session`).
+ #[error("invalid or missing CSRF token")]
+ BadCsrf,
+
+ /// No session cookie was presented, or it named a session this server
+ /// no longer holds in memory (`roots.web-session`): the process
+ /// restarted, or the cookie is forged.
+ #[error("no valid session")]
+ NoSession,
+
+ /// A `gix-ref-store` failure: reading or writing a ref.
+ #[error(transparent)]
+ Refs(#[from] gix_ref_store::Error),
+
+ /// An `ents-model` failure: building or validating a refname or typed
+ /// tree.
+ #[error(transparent)]
+ Model(#[from] ents_model::Error),
+
+ /// A `facet-git-tree` (de)serialization failure.
+ #[error(transparent)]
+ Tree(#[from] facet_git_tree::Error),
+
+ /// An `ents-anchor` failure: capturing or projecting a code anchor.
+ #[error(transparent)]
+ Anchor(#[from] ents_anchor::Error),
+
+ /// An `ents-forge` failure: anchoring, serializing, or proposing a
+ /// comment mutation. Boxed: `ents_forge::Error` is large enough on its
+ /// own to trip `clippy::result_large_err` if stored inline (mirrors
+ /// `git-ents::error::Error::Forge`'s identical boxing).
+ #[error(transparent)]
+ Forge(Box<ents_forge::Error>),
+
+ /// An `ents-effect` failure: toolchain resolution or import (the error
+ /// type `ents-kiln`'s own toolchain module reuses as-is, per that
+ /// crate's own doc). Boxed; see [`Error::Forge`]'s own doc.
+ #[error(transparent)]
+ Effect(Box<ents_effect::Error>),
+
+ /// An `ents-receive` failure: `receive` itself could not reach an
+ /// outcome. Boxed; see [`Error::Forge`]'s own doc.
+ #[error(transparent)]
+ Receive(Box<ents_receive::Error>),
+}
+
+impl From<ents_forge::Error> for Error {
+ fn from(source: ents_forge::Error) -> Self {
+ Self::Forge(Box::new(source))
+ }
+}
+
+impl From<ents_effect::Error> for Error {
+ fn from(source: ents_effect::Error) -> Self {
+ Self::Effect(Box::new(source))
+ }
+}
+
+impl From<ents_receive::Error> for Error {
+ fn from(source: ents_receive::Error) -> Self {
+ Self::Receive(Box::new(source))
+ }
+}
+
+/// Translate a reached [`ents_receive::Outcome`] into `Ok(())` on success or
+/// an [`Error`] otherwise — this crate's counterpart to
+/// `git_ents::mutate::outcome_to_result`, kept as a free function here for
+/// exactly the same reason: every page that proposes a mutation renders a
+/// refusal identically.
+///
+/// # Errors
+///
+/// [`Error::Refused`], [`Error::Stale`], or [`Error::Redacted`]; see
+/// `git_ents::mutate::outcome_to_result` for the identical mapping this
+/// mirrors.
+pub fn outcome_to_result(outcome: ents_receive::Outcome) -> Result<()> {
+ match outcome.result {
+ ents_receive::TxResult::Applied => Ok(()),
+ ents_receive::TxResult::Refused => {
+ let reasons = outcome
+ .verdicts
+ .iter()
+ .filter_map(|(_, verdict)| match verdict {
+ ents_gate::Verdict::Fail(refusal) => Some(refusal.to_string()),
+ ents_gate::Verdict::Pass(_) => None,
+ })
+ .collect::<Vec<_>>()
+ .join("; ");
+ Err(Error::Refused(reasons))
+ }
+ ents_receive::TxResult::Rejected { name } => Err(Error::Stale {
+ name: name.as_bstr().to_string(),
+ }),
+ ents_receive::TxResult::Redacted { oid } => Err(Error::Redacted { oid }),
+ }
+}
+
+/// This crate's `Result` alias.
+pub type Result<T> = std::result::Result<T, Error>;
+
+impl axum::response::IntoResponse for Error {
+ fn into_response(self) -> axum::response::Response {
+ use axum::http::StatusCode;
+
+ let status = match &self {
+ Error::NotFound { .. } => StatusCode::NOT_FOUND,
+ Error::InvalidArgument(_) | Error::BadCsrf => StatusCode::BAD_REQUEST,
+ Error::NoSession => StatusCode::UNAUTHORIZED,
+ Error::Refused(_) | Error::Stale { .. } | Error::Redacted { .. } => {
+ StatusCode::CONFLICT
+ }
+ _ => StatusCode::INTERNAL_SERVER_ERROR,
+ };
+ (status, self.to_string()).into_response()
+ }
+}
crates/cli/ents-web/src/identity.rs
@@ -1,0 +1,88 @@
+//! The signing-identity seam (`roots.web-agnostic`, `roots.web-signing`):
+//! the one new trait this crate introduces, because gitoxide and the
+//! kernel are both silent on "who signs a web-originated commit" -- exactly
+//! the carve-out `arch.no-object-store-trait` reserves for "the pluggable
+//! ref store, server-side receive framing, and reachability artifacts...
+//! and new seams where upstream is silent."
+//!
+//! Every page that proposes a mutation is handed a
+//! `&dyn SigningIdentity` through [`crate::state::AppState`]; nothing in
+//! [`crate::pages`] ever loads a key, resolves `user.signingkey`, or knows
+//! whether the identity behind it belongs to the local operator or a
+//! hosted server's own worker account. That is the whole point
+//! (`roots.web-signing`): a hosted deployment's composition root wires an
+//! identity backed by the server's own enrolled member key, a local
+//! deployment's wires one backed by the user's own key
+//! (`git_ents::sign::Signer`, injected by `git-ents`'s own `serve`
+//! command) -- both satisfy this same trait, and no branch anywhere in
+//! this crate asks which one it was handed.
+
+/// Everything a page handler needs to sign a mutation commit on behalf of
+/// the current request, injected by the composition root
+/// (`roots.web-agnostic`).
+///
+/// # Examples
+///
+/// A fixture identity, standing in for either a local user's key or a
+/// hosted server's worker key -- [`crate::pages`] cannot tell which from
+/// this trait alone, which is exactly `roots.web-signing`'s requirement.
+///
+/// ```
+/// use ents_web::identity::SigningIdentity;
+///
+/// struct Fixed;
+/// impl SigningIdentity for Fixed {
+/// fn actor(&self) -> gix::actor::Signature {
+/// gix::actor::Signature {
+/// name: "fixture".into(),
+/// email: "fixture@ents.test".into(),
+/// time: gix::date::Time { seconds: 0, offset: 0 },
+/// }
+/// }
+/// fn sign(&self, _payload: &[u8]) -> String {
+/// "-----BEGIN SSH SIGNATURE-----\n-----END SSH SIGNATURE-----\n".to_owned()
+/// }
+/// fn public_openssh(&self) -> String {
+/// "ssh-ed25519 AAAA... fixture".to_owned()
+/// }
+/// }
+///
+/// let identity: Box<dyn SigningIdentity> = Box::new(Fixed);
+/// assert_eq!(identity.actor().name, "fixture");
+/// ```
+// @relation(roots.web-signing, roots.web-agnostic, scope=file)
+pub trait SigningIdentity: Send + Sync {
+ /// The commit author/committer signature every mutation this identity
+ /// signs carries.
+ fn actor(&self) -> gix::actor::Signature;
+
+ /// Sign `payload` (a commit's to-be-signed bytes), returning the
+ /// armored SSHSIG PEM block for the commit's `gpgsig` header.
+ fn sign(&self, payload: &[u8]) -> String;
+
+ /// The public half of this identity's key, in OpenSSH single-line
+ /// format -- used to resolve which enrolled [`ents_model::Member`] is
+ /// acting, exactly as `git ents account create` resolves its own
+ /// signer's member when `--member` is omitted.
+ fn public_openssh(&self) -> String;
+}
+
+/// Build the [`ents_receive::Identity`] every mutation page hands to
+/// `propose_entity`/`propose_delete`.
+///
+/// This is a macro, not a function, deliberately: `ents_receive::Identity`
+/// borrows its `sign` closure (`sign: &'a dyn Fn(&[u8]) -> String`), so the
+/// closure literal must live in the caller's own stack frame -- a helper
+/// function that built and returned an `Identity` would return a
+/// reference to a temporary dropped at that function's end. Every page in
+/// [`crate::pages`] expands this at its own call site instead, exactly the
+/// shape `git_ents::commands::comment::add` and its siblings already use.
+#[macro_export]
+macro_rules! receive_identity {
+ ($identity:expr) => {
+ ents_receive::Identity {
+ actor: $identity.actor(),
+ sign: &|payload| $identity.sign(payload),
+ }
+ };
+}
crates/cli/ents-web/src/lib.rs
@@ -1,0 +1,118 @@
+//! `ents-web`: the web UI (`docs/development-plan.adoc`, phase 7) --
+//! a second leaf, sibling to `git-ents`, in the layering
+//! `docs/abstractions.adoc` states (`substrate -> kernel -> {forge, kiln}
+//! -> {git-ents, ents-web}`).
+//!
+//! This crate's one responsibility is rendering the kernel's and every
+//! installed package's own state as HTML, and accepting signed,
+//! CSRF-checked mutations back -- never a second copy of forge or kiln
+//! business logic. Every page is a thin caller into `ents-model`,
+//! `ents-anchor`, `ents-query`, `ents-receive`, `ents-forge`, or
+//! `ents-kiln`, exactly as `git-ents`'s own `commands` modules are thin
+//! callers into the same crates ([`crate::pages`]'s own module doc draws
+//! the line between the generic, reflection-driven pages and the
+//! legitimate custom ones).
+//!
+//! # Deployment-agnostic by construction (`roots.web-agnostic`)
+//!
+//! Nothing in this crate binds a socket except [`serve_on`], and nothing
+//! upstream of it assumes one exists: `router()` alone builds a complete,
+//! in-process `tower::Service` a caller can drive via
+//! `tower::ServiceExt::oneshot` with no network transport at all -- the
+//! same shape an in-process webview embedding would drive a request
+//! through. See [`identity::SigningIdentity`]'s own doc for the other half
+//! of this requirement: the signing identity a mutation is signed with is
+//! always injected by the composition root, never resolved by this crate
+//! itself.
+//!
+//! # What this crate does not expose (`roots.local`)
+//!
+//! There is no `/info/refs`, no `git-upload-pack`/`git-receive-pack`
+//! route, and no code path that shells to `git` as a smart-HTTP backend
+//! anywhere in `router()`'s route table. `git ents serve`'s own doc
+//! (`git-ents`'s `commands::serve`) states why: the local root's existing
+//! wiring already serves git's own transport for the test-harness case
+//! (`roots.worktree-update`); this crate adds only the web UI on top of
+//! it, on loopback, never a second git-serving surface.
+//!
+//! # Spec coverage
+//!
+//! From `docs/spec/roots.adoc`:
+//!
+//! - `roots.local` -- this crate's route table carries no git
+//! smart-HTTP surface; `git-ents`'s own `serve` command reuses
+//! `LocalRoot`'s existing seams and binds loopback only (see that
+//! crate's `commands::serve` module).
+//! - `roots.web-signing`, `roots.web-agnostic` -- [`identity::SigningIdentity`].
+//! - `roots.web-session` -- [`session::SessionStore`], and
+//! `pages::require_csrf` on every state-changing route.
+//!
+//! `roots.path-validation` and `roots.fetch-auth` are out of scope for
+//! this crate: both describe `git-ents-server`'s multi-repository hosted
+//! root (phase 8) -- "reject a path that would escape the data
+//! directory, nest inside an existing repository, or collide with a
+//! non-repository namespace directory" and "private-repository access...
+//! out of scope for v1" both presuppose a data directory holding more
+//! than one repository, which does not exist until that phase. This
+//! crate's composition root always already has exactly one, already-open
+//! repository.
+//!
+//! # Examples
+//!
+//! Driving a full request through this crate with no socket bound at all
+//! (`roots.web-agnostic`'s in-process case) -- see `tests/router.rs` for
+//! the full-fixture version of this same shape, wired against a real
+//! signed member.
+//!
+//! ```
+//! use std::sync::Arc;
+//!
+//! use ents_web::identity::SigningIdentity;
+//! use ents_web::state::AppState;
+//! use ents_receive::{Mode, NullEventSink};
+//! use ents_testutil::ObjectStore;
+//! use gix_ref_store::LooseRefStore;
+//! use http_body_util::BodyExt as _;
+//! use tower::ServiceExt as _;
+//!
+//! struct Fixture;
+//! impl SigningIdentity for Fixture {
+//! fn actor(&self) -> gix::actor::Signature {
+//! gix::actor::Signature {
+//! name: "fixture".into(), email: "fixture@ents.test".into(),
+//! time: gix::date::Time { seconds: 0, offset: 0 },
+//! }
+//! }
+//! fn sign(&self, _payload: &[u8]) -> String { String::new() }
+//! fn public_openssh(&self) -> String { "ssh-ed25519 AAAA... fixture".to_owned() }
+//! }
+//!
+//! # let runtime = tokio::runtime::Runtime::new().expect("runtime");
+//! # runtime.block_on(async {
+//! let dir = tempfile::tempdir().expect("tempdir");
+//! gix::init(dir.path()).expect("init");
+//! let refs = LooseRefStore::open(dir.path()).expect("opens");
+//! let objects = ObjectStore::default();
+//! let state = Arc::new(AppState::new(
+//! Box::new(refs), objects, Box::new(NullEventSink), Mode::Advisory,
+//! Box::new(Fixture), dir.path().to_owned(),
+//! ));
+//! let router = ents_web::router(state);
+//! let response = router
+//! .oneshot(axum::http::Request::get("/").body(axum::body::Body::empty()).expect("request"))
+//! .await
+//! .expect("in-process call");
+//! assert_eq!(response.status(), axum::http::StatusCode::OK);
+//! # });
+//! ```
+
+pub mod error;
+pub mod identity;
+pub mod pages;
+pub mod render;
+pub mod router;
+pub mod session;
+pub mod state;
+
+pub use error::{Error, Result};
+pub use router::{bind, router, serve_on};
crates/cli/ents-web/src/pages/account.rs
@@ -1,0 +1,174 @@
+//! `GET /account`, `POST /account`: the generic *view* of
+//! [`ents_model::Account`] (`crate::render::view`, reflection-driven, the
+//! same mechanism [`super::members`] and [`super::redactions`] use), paired
+//! with this crate's one demonstrated generic-edit write flow
+//! (`roots.web-session`'s signed, CSRF-checked mutation path).
+//!
+//! Account is the write-flow demo rather than every entity because it is
+//! the simplest possible case -- two string-shaped fields, one fixed ref,
+//! no anchor or recipe machinery to special-case -- so the CSRF/session/
+//! signing plumbing this page exercises is visible without also chasing a
+//! more complex entity's own domain logic. Every other write flow this
+//! crate ships ([`super::comments::add`]) is a legitimate custom page for
+//! exactly the reason `ents-forge`'s own comment command is: anchoring
+//! needs a repository checkout and a projection, not a bare form.
+
+use std::sync::Arc;
+
+use axum::Form;
+use axum::extract::State;
+use axum::response::{IntoResponse, Redirect};
+use ents_model::{Account, Member, MemberId, namespace};
+use ents_receive::propose_entity;
+use gix_object::{Find, Write};
+use maud::html;
+use serde::Deserialize;
+
+use crate::error::{Error, Result};
+use crate::session::Session;
+use crate::state::AppState;
+
+/// `GET /account`: the current account (if one exists), plus a form to
+/// create or update it.
+///
+/// # Errors
+///
+/// Propagates a ref-store or object read failure.
+pub async fn show<O>(
+ State(state): State<Arc<AppState<O>>>,
+ axum::Extension(session): axum::Extension<Session>,
+) -> Result<maud::Markup>
+where
+ O: Find + Write + Send + 'static,
+{
+ let current = read(&state)?;
+ let (member_value, login_value) = match ¤t {
+ Some(account) => (account.member.as_str().to_owned(), account.login.clone()),
+ None => (String::new(), String::new()),
+ };
+ let view = current
+ .as_ref()
+ .map(crate::render::view)
+ .unwrap_or_else(|| html! { p { "no account created yet" } });
+
+ Ok(super::layout(
+ "account",
+ html! {
+ (view)
+ h2 { "create or update" }
+ form method="post" action="/account" {
+ (super::csrf_input(&session))
+ label { "member" input type="text" name="member" value=(member_value); }
+ label { "login" input type="text" name="login" value=(login_value); }
+ button type="submit" { "save" }
+ }
+ },
+ ))
+}
+
+/// The form fields `POST /account` accepts.
+#[derive(Debug, Deserialize)]
+pub struct AccountForm {
+ /// The member this account belongs to; if blank, resolved from the
+ /// signing identity's own enrolled key (mirrors
+ /// `git_ents::commands::account::create`'s identical default).
+ #[serde(default)]
+ member: String,
+ /// The login identity to record.
+ login: String,
+ /// The per-session CSRF token (`roots.web-session`).
+ csrf: String,
+}
+
+/// `POST /account`: create or update the account, signed
+/// (`roots.web-signing`) on behalf of the current session
+/// (`roots.web-session`).
+///
+/// # Errors
+///
+/// [`Error::BadCsrf`] if `form.csrf` does not match the session's own
+/// token; [`Error::NotFound`] if `member` is blank and the signing
+/// identity's key is not an enrolled member; otherwise propagates a
+/// serialization or `receive` failure.
+// @relation(roots.web-signing, roots.web-session, scope=function)
+pub async fn update<O>(
+ State(state): State<Arc<AppState<O>>>,
+ axum::Extension(session): axum::Extension<Session>,
+ Form(form): Form<AccountForm>,
+) -> Result<impl IntoResponse>
+where
+ O: Find + Write + Send + 'static,
+{
+ super::require_csrf(&session, &form.csrf)?;
+
+ let member = if form.member.trim().is_empty() {
+ resolve_member_by_key(&state, &state.identity.public_openssh())?
+ } else {
+ MemberId::new(form.member.trim())
+ };
+ let account = Account {
+ member,
+ login: form.login,
+ };
+
+ #[expect(
+ clippy::expect_used,
+ reason = "ACCOUNT_REF is a fixed, compile-time-known-valid refname literal, mirroring \
+ git_ents::commands::account's identical unguarded conversion"
+ )]
+ let name: gix::refs::FullName = namespace::ACCOUNT_REF
+ .try_into()
+ .expect("fixed, valid refname");
+
+ let identity = state.identity.as_ref();
+ let outcome = propose_entity(
+ state.refs.as_ref(),
+ &*state.objects(),
+ state.events.as_ref(),
+ name,
+ &account,
+ &crate::receive_identity!(identity),
+ "Create account (web)",
+ state.mode,
+ )?;
+ crate::error::outcome_to_result(outcome)?;
+ Ok(Redirect::to("/account"))
+}
+
+fn read<O: Find>(state: &AppState<O>) -> Result<Option<Account>> {
+ #[expect(
+ clippy::expect_used,
+ clippy::unwrap_in_result,
+ reason = "ACCOUNT_REF is a fixed, compile-time-known-valid refname literal"
+ )]
+ let name: gix::refs::FullName = namespace::ACCOUNT_REF
+ .try_into()
+ .expect("fixed, valid refname");
+ let Some(tip) = state.refs.get(name.as_ref())? else {
+ return Ok(None);
+ };
+ let tree = super::commit_tree(&*state.objects(), tip)?;
+ Ok(Some(facet_git_tree::deserialize::<Account>(
+ &tree,
+ &*state.objects(),
+ )?))
+}
+
+fn resolve_member_by_key<O: Find>(state: &AppState<O>, pubkey: &str) -> Result<MemberId> {
+ for entry in state.refs.iter_prefix("refs/meta/member/")? {
+ let (name, tip) = entry?;
+ let path = name.as_bstr().to_string();
+ let Some(username) = path.strip_prefix("refs/meta/member/") else {
+ continue;
+ };
+ let tree = super::commit_tree(&*state.objects(), tip)?;
+ if let Ok(member) = facet_git_tree::deserialize::<Member>(&tree, &*state.objects())
+ && member.key == pubkey
+ {
+ return Ok(MemberId::new(username));
+ }
+ }
+ Err(Error::NotFound {
+ what: "member for the current signing identity".to_owned(),
+ })
+}
crates/cli/ents-web/src/pages/comments.rs
@@ -1,0 +1,162 @@
+//! `GET /comments`, `GET /comments/{id}`, `POST /comments`: a custom (not
+//! generic) page family, per this crate's own top-level doc -- a
+//! comment's anchor needs projection against a live working tree
+//! (`anchor.projection`) to render meaningfully, which is exactly the
+//! kind of domain-specific view `ents-forge`'s own `comment::show`
+//! already returns structured data for, rather than a bare reflected
+//! field list.
+
+use std::sync::Arc;
+
+use axum::Form;
+use axum::extract::{Path, Query as PathQuery, State};
+use axum::response::{IntoResponse, Redirect};
+use ents_forge::comment;
+use gix_object::{Find, Write};
+use maud::html;
+use serde::Deserialize;
+
+use crate::error::Result;
+use crate::session::Session;
+use crate::state::AppState;
+
+/// `GET /comments`.
+///
+/// # Errors
+///
+/// Propagates a ref-store or object read failure.
+pub async fn list<O>(
+ State(state): State<Arc<AppState<O>>>,
+ axum::Extension(session): axum::Extension<Session>,
+) -> Result<maud::Markup>
+where
+ O: Find + Write + Send + 'static,
+{
+ let rows = comment::list(state.refs.as_ref(), &*state.objects())?;
+ Ok(super::layout(
+ "comments",
+ html! {
+ ul {
+ @for (id, comment) in &rows {
+ li { a href=(format!("/comments/{id}")) { (id) } ": " (comment.body) }
+ }
+ }
+ h2 { "add a comment" }
+ (add_form("HEAD", &session))
+ },
+ ))
+}
+
+/// The query parameters `GET /comments/{id}` accepts: which revision to
+/// project the anchor onto (defaults to `HEAD`).
+#[derive(Debug, Deserialize)]
+pub struct ShowQuery {
+ /// The revision to project onto; defaults to `HEAD`.
+ #[serde(default = "default_rev_field")]
+ rev: String,
+}
+
+fn default_rev_field() -> String {
+ "HEAD".to_owned()
+}
+
+/// `GET /comments/{id}?rev=...`: the comment's body, its anchor, and the
+/// projection of that anchor onto `rev` (`anchor.projection`).
+///
+/// # Errors
+///
+/// [`crate::Error::Forge`] (wrapping [`ents_forge::Error::NotFound`]) if
+/// `id` has no comment ref.
+pub async fn show<O>(
+ State(state): State<Arc<AppState<O>>>,
+ Path(id): Path<String>,
+ PathQuery(query): PathQuery<ShowQuery>,
+) -> Result<maud::Markup>
+where
+ O: Find + Write + Send + 'static,
+{
+ let (comment, anchor, projection) = comment::show(
+ state.refs.as_ref(),
+ &*state.objects(),
+ &state.path,
+ &id,
+ &query.rev,
+ )?;
+ Ok(super::layout(
+ &id,
+ html! {
+ dl {
+ dt { "path" } dd { (anchor.path) }
+ dt { "lines" } dd { (format!("{:?}", anchor.lines)) }
+ dt { "projection at " (query.rev) } dd { (format!("{projection:?}")) }
+ dt { "body" } dd { (comment.body) }
+ }
+ },
+ ))
+}
+
+/// The form fields `POST /comments` accepts.
+#[derive(Debug, Deserialize)]
+pub struct AddForm {
+ /// The repository-relative path to anchor to.
+ path: String,
+ /// The comment's text.
+ body: String,
+ /// An optional `<start>[:<end>]` line range.
+ #[serde(default)]
+ lines: String,
+ /// The revision to anchor against.
+ rev: String,
+ /// The per-session CSRF token (`roots.web-session`).
+ csrf: String,
+}
+
+/// `POST /comments`: anchor `body` to `path` at `rev`, signed
+/// (`roots.web-signing`) on behalf of the current session
+/// (`roots.web-session`).
+///
+/// # Errors
+///
+/// [`crate::Error::BadCsrf`] if `form.csrf` does not match; otherwise
+/// propagates [`ents_forge::comment::add`]'s own failures.
+// @relation(roots.web-signing, roots.web-session, scope=function)
+pub async fn add<O>(
+ State(state): State<Arc<AppState<O>>>,
+ axum::Extension(session): axum::Extension<Session>,
+ Form(form): Form<AddForm>,
+) -> Result<impl IntoResponse>
+where
+ O: Find + Write + Send + 'static,
+{
+ super::require_csrf(&session, &form.csrf)?;
+ let lines = (!form.lines.trim().is_empty()).then(|| form.lines.trim().to_owned());
+
+ let identity = state.identity.as_ref();
+ let (id, outcome) = comment::add(
+ state.refs.as_ref(),
+ &*state.objects(),
+ state.events.as_ref(),
+ &state.path,
+ &form.path,
+ form.body,
+ lines,
+ &form.rev,
+ &crate::receive_identity!(identity),
+ state.mode,
+ )?;
+ crate::error::outcome_to_result(outcome)?;
+ Ok(Redirect::to(&format!("/comments/{id}")))
+}
+
+fn add_form(default_rev: &str, session: &Session) -> maud::Markup {
+ html! {
+ form method="post" action="/comments" {
+ (super::csrf_input(session))
+ label { "path" input type="text" name="path"; }
+ label { "rev" input type="text" name="rev" value=(default_rev); }
+ label { "lines" input type="text" name="lines"; }
+ label { "body" textarea name="body" {} }
+ button type="submit" { "comment" }
+ }
+ }
+}
crates/cli/ents-web/src/pages/dashboard.rs
@@ -1,0 +1,44 @@
+//! `GET /`: the dashboard -- entry points into every page family this
+//! crate exposes, with a live count read from each namespace so the page
+//! doubles as a smoke test that every seam in [`crate::state::AppState`]
+//! actually reads.
+
+use std::sync::Arc;
+
+use axum::extract::State;
+use gix_object::{Find, Write};
+use maud::html;
+
+use crate::error::Result;
+use crate::state::AppState;
+
+/// `GET /`.
+///
+/// # Errors
+///
+/// Propagates a ref-store read failure.
+pub async fn show<O>(State(state): State<Arc<AppState<O>>>) -> Result<maud::Markup>
+where
+ O: Find + Write + Send + 'static,
+{
+ let members = state.refs.iter_prefix("refs/meta/member/")?.count();
+ let effects = state.refs.iter_prefix("refs/meta/effects/")?.count();
+ let redactions = state.refs.iter_prefix("refs/meta/redactions/")?.count();
+ let comments = state.refs.iter_prefix("refs/meta/comments/")?.count();
+ let toolchains = state.refs.iter_prefix("refs/meta/toolchains/")?.count();
+
+ Ok(super::layout(
+ "dashboard",
+ html! {
+ ul {
+ li { a href="/members" { "members" } " (" (members) ")" }
+ li { a href="/account" { "account" } }
+ li { a href="/effects" { "effects" } " (" (effects) ")" }
+ li { a href="/redactions" { "redactions" } " (" (redactions) ")" }
+ li { a href="/toolchains" { "toolchains" } " (" (toolchains) ")" }
+ li { a href="/comments" { "comments" } " (" (comments) ")" }
+ li { a href="/inbox" { "inbox" } }
+ }
+ },
+ ))
+}
crates/cli/ents-web/src/pages/effects.rs
@@ -1,0 +1,81 @@
+//! `GET /effects`, `GET /effects/{name}`: the generic list/view pair for
+//! [`ents_model::Effect`], plus a light, genuine use of `ents-query`
+//! (`overview.adoc`'s crate-graph row for this crate names it as a
+//! dependency): the show page re-parses the effect's own trigger text as a
+//! [`ents_query::Query`] and reports whether it still parses, exactly the
+//! tolerance check `git_ents::hook::read_effect` already performs on the
+//! hosted root before running an effect.
+
+use std::sync::Arc;
+
+use axum::extract::{Path, State};
+use ents_model::Effect;
+use ents_query::Query;
+use gix_object::{Find, Write};
+use maud::html;
+
+use crate::error::{Error, Result};
+use crate::state::AppState;
+
+/// `GET /effects`.
+///
+/// # Errors
+///
+/// Propagates a ref-store or object read failure.
+pub async fn list<O>(State(state): State<Arc<AppState<O>>>) -> Result<maud::Markup>
+where
+ O: Find + Write + Send + 'static,
+{
+ let rows = read_all(&state)?;
+ Ok(super::layout(
+ "effects",
+ crate::render::list_table(&rows, "name", |id| format!("/effects/{id}")),
+ ))
+}
+
+/// `GET /effects/{name}`.
+///
+/// # Errors
+///
+/// [`Error::NotFound`] if `name` has no effect ref.
+pub async fn show<O>(
+ State(state): State<Arc<AppState<O>>>,
+ Path(name): Path<String>,
+) -> Result<maud::Markup>
+where
+ O: Find + Write + Send + 'static,
+{
+ let (_, effect) = read_all(&state)?
+ .into_iter()
+ .find(|(id, _)| *id == name)
+ .ok_or_else(|| Error::NotFound {
+ what: format!("effect {name}"),
+ })?;
+ let query_status = match effect.trigger.parse::<Query>() {
+ Ok(_) => "parses".to_owned(),
+ Err(error) => format!("does not parse: {error}"),
+ };
+ Ok(super::layout(
+ &name,
+ html! {
+ (crate::render::view(&effect))
+ p { "trigger query: " (query_status) }
+ },
+ ))
+}
+
+fn read_all<O: Find>(state: &AppState<O>) -> Result<Vec<(String, Effect)>> {
+ let mut out = Vec::new();
+ for entry in state.refs.iter_prefix("refs/meta/effects/")? {
+ let (name, tip) = entry?;
+ let path = name.as_bstr().to_string();
+ let Some(id) = path.strip_prefix("refs/meta/effects/") else {
+ continue;
+ };
+ let tree = super::commit_tree(&*state.objects(), tip)?;
+ if let Ok(effect) = facet_git_tree::deserialize::<Effect>(&tree, &*state.objects()) {
+ out.push((id.to_owned(), effect));
+ }
+ }
+ Ok(out)
+}
crates/cli/ents-web/src/pages/inbox.rs
@@ -1,0 +1,36 @@
+//! `GET /inbox`: every `refs/meta/inbox/<member>/<id>` entry awaiting
+//! adoption -- read-only in this phase (`sync.adoption-machinery`'s merge
+//! itself stays a `git ents inbox adopt` operation; this crate has no
+//! write path for it, since adoption needs a working-tree-aware three-way
+//! merge, not a signed-commit form).
+
+use std::sync::Arc;
+
+use axum::extract::State;
+use gix_object::{Find, Write};
+
+use crate::error::Result;
+use crate::state::AppState;
+
+/// `GET /inbox`.
+///
+/// # Errors
+///
+/// Propagates a ref-store read failure.
+pub async fn list<O>(State(state): State<Arc<AppState<O>>>) -> Result<maud::Markup>
+where
+ O: Find + Write + Send + 'static,
+{
+ let mut rows = Vec::new();
+ for entry in state.refs.iter_prefix("refs/meta/inbox/")? {
+ let (name, _) = entry?;
+ let path = name.as_bstr().to_string();
+ if let Some(rest) = path.strip_prefix("refs/meta/inbox/") {
+ rows.push(rest.to_owned());
+ }
+ }
+ Ok(super::layout(
+ "inbox",
+ crate::render::string_list(&rows, |_| "/inbox".to_owned()),
+ ))
+}
crates/cli/ents-web/src/pages/members.rs
@@ -1,0 +1,67 @@
+//! `GET /members`, `GET /members/{username}`: the generic list/view pair
+//! for [`ents_model::Member`] -- read-only in this phase (enrollment stays
+//! a `git ents members add` operation; see this crate's own top-level doc
+//! for why write flows are demonstrated on [`super::account`] rather than
+//! duplicated per entity).
+
+use std::sync::Arc;
+
+use axum::extract::{Path, State};
+use ents_model::Member;
+use gix_object::{Find, Write};
+
+use crate::error::{Error, Result};
+use crate::state::AppState;
+
+/// `GET /members`.
+///
+/// # Errors
+///
+/// Propagates a ref-store or object read failure.
+pub async fn list<O>(State(state): State<Arc<AppState<O>>>) -> Result<maud::Markup>
+where
+ O: Find + Write + Send + 'static,
+{
+ let rows = read_all(&state)?;
+ Ok(super::layout(
+ "members",
+ crate::render::list_table(&rows, "username", |id| format!("/members/{id}")),
+ ))
+}
+
+/// `GET /members/{username}`.
+///
+/// # Errors
+///
+/// [`Error::NotFound`] if `username` has no member ref.
+pub async fn show<O>(
+ State(state): State<Arc<AppState<O>>>,
+ Path(username): Path<String>,
+) -> Result<maud::Markup>
+where
+ O: Find + Write + Send + 'static,
+{
+ let (_, member) = read_all(&state)?
+ .into_iter()
+ .find(|(name, _)| *name == username)
+ .ok_or_else(|| Error::NotFound {
+ what: format!("member {username}"),
+ })?;
+ Ok(super::layout(&username, crate::render::view(&member)))
+}
+
+fn read_all<O: Find>(state: &AppState<O>) -> Result<Vec<(String, Member)>> {
+ let mut out = Vec::new();
+ for entry in state.refs.iter_prefix("refs/meta/member/")? {
+ let (name, tip) = entry?;
+ let path = name.as_bstr().to_string();
+ let Some(username) = path.strip_prefix("refs/meta/member/") else {
+ continue;
+ };
+ let tree = super::commit_tree(&*state.objects(), tip)?;
+ if let Ok(member) = facet_git_tree::deserialize::<Member>(&tree, &*state.objects()) {
+ out.push((username.to_owned(), member));
+ }
+ }
+ Ok(out)
+}
crates/cli/ents-web/src/pages/mod.rs
@@ -1,0 +1,106 @@
+//! One module per page family -- `crate::router`'s handlers given a
+//! body, mirroring `git_ents::commands`'s "one module per subcommand
+//! family" convention on the web side.
+//!
+//! [`dashboard`], [`members`], [`account`], [`effects`], [`redactions`],
+//! and [`inbox`] are the generic pages: they read a kernel entity and
+//! render it through [`crate::render`]'s reflection-driven mechanism,
+//! never matching on which entity type they were handed.
+//! [`toolchains`] and [`comments`] are legitimate custom pages
+//! (`ents-kiln`'s recipe provenance and `ents-forge`'s anchor projection
+//! both need domain-specific rendering no generic reflection walk should
+//! grow special cases for).
+
+pub mod account;
+pub mod comments;
+pub mod dashboard;
+pub mod effects;
+pub mod inbox;
+pub mod members;
+pub mod redactions;
+pub mod toolchains;
+
+use gix_hash::ObjectId;
+use gix_object::{CommitRef, Find, Kind};
+use maud::{Markup, html};
+
+use crate::error::{Error, Result};
+use crate::session::{CSRF_FIELD, Session};
+
+/// The tree of the commit at `oid` -- every page that reads back a typed
+/// entity needs this; mirrors `git_ents::commands::commit_tree` and
+/// `ents_forge::comment::command`'s own identical, independently
+/// duplicated helper (that module's own doc names this the accepted
+/// pattern in this codebase).
+pub(crate) fn commit_tree(objects: &impl Find, oid: ObjectId) -> Result<ObjectId> {
+ let mut buf = Vec::new();
+ let data = objects
+ .try_find(&oid, &mut buf)
+ .map_err(|source| Error::InvalidArgument(source.to_string()))?
+ .ok_or_else(|| Error::NotFound {
+ what: oid.to_string(),
+ })?;
+ if data.kind != Kind::Commit {
+ return Err(Error::NotFound {
+ what: oid.to_string(),
+ });
+ }
+ let commit = CommitRef::from_bytes(data.data, oid.kind())
+ .map_err(|source| Error::InvalidArgument(source.to_string()))?;
+ Ok(commit.tree())
+}
+
+/// Wrap `title` and `body` in the one page shell every route renders
+/// through -- navigation to every generic and custom page family this
+/// crate exposes.
+pub(crate) fn layout(title: &str, body: Markup) -> Markup {
+ html! {
+ (maud::DOCTYPE)
+ html {
+ head {
+ meta charset="utf-8";
+ title { "git ents: " (title) }
+ }
+ body {
+ nav {
+ a href="/" { "dashboard" } " | "
+ a href="/members" { "members" } " | "
+ a href="/account" { "account" } " | "
+ a href="/effects" { "effects" } " | "
+ a href="/redactions" { "redactions" } " | "
+ a href="/toolchains" { "toolchains" } " | "
+ a href="/comments" { "comments" } " | "
+ a href="/inbox" { "inbox" }
+ }
+ hr;
+ h1 { (title) }
+ (body)
+ }
+ }
+ }
+}
+
+/// A hidden CSRF input every form this crate renders carries
+/// (`roots.web-session`): the one place that field is spelled, so a form
+/// can never omit it by a typo.
+pub(crate) fn csrf_input(session: &Session) -> Markup {
+ html! {
+ input type="hidden" name=(CSRF_FIELD) value=(session.csrf);
+ }
+}
+
+/// Verify `submitted` matches `session`'s own CSRF token
+/// (`roots.web-session`): every state-changing handler calls this before
+/// acting on a form body.
+///
+/// # Errors
+///
+/// [`Error::BadCsrf`] if `submitted` does not match.
+// @relation(roots.web-session, scope=function)
+pub(crate) fn require_csrf(session: &Session, submitted: &str) -> Result<()> {
+ if submitted == session.csrf {
+ Ok(())
+ } else {
+ Err(Error::BadCsrf)
+ }
+}
crates/cli/ents-web/src/pages/redactions.rs
@@ -1,0 +1,66 @@
+//! `GET /redactions`, `GET /redactions/{id}`: the generic list/view pair
+//! for [`ents_model::Redaction`] -- read-only in this phase (recording a
+//! redaction stays a `git ents redact add` operation, admin-only per the
+//! gate's default namespace-authorization arm).
+
+use std::sync::Arc;
+
+use axum::extract::{Path, State};
+use ents_model::Redaction;
+use gix_object::{Find, Write};
+
+use crate::error::{Error, Result};
+use crate::state::AppState;
+
+/// `GET /redactions`.
+///
+/// # Errors
+///
+/// Propagates a ref-store or object read failure.
+pub async fn list<O>(State(state): State<Arc<AppState<O>>>) -> Result<maud::Markup>
+where
+ O: Find + Write + Send + 'static,
+{
+ let rows = read_all(&state)?;
+ Ok(super::layout(
+ "redactions",
+ crate::render::list_table(&rows, "id", |id| format!("/redactions/{id}")),
+ ))
+}
+
+/// `GET /redactions/{id}`.
+///
+/// # Errors
+///
+/// [`Error::NotFound`] if `id` has no redaction ref.
+pub async fn show<O>(
+ State(state): State<Arc<AppState<O>>>,
+ Path(id): Path<String>,
+) -> Result<maud::Markup>
+where
+ O: Find + Write + Send + 'static,
+{
+ let (_, redaction) = read_all(&state)?
+ .into_iter()
+ .find(|(rid, _)| *rid == id)
+ .ok_or_else(|| Error::NotFound {
+ what: format!("redaction {id}"),
+ })?;
+ Ok(super::layout(&id, crate::render::view(&redaction)))
+}
+
+fn read_all<O: Find>(state: &AppState<O>) -> Result<Vec<(String, Redaction)>> {
+ let mut out = Vec::new();
+ for entry in state.refs.iter_prefix("refs/meta/redactions/")? {
+ let (name, tip) = entry?;
+ let path = name.as_bstr().to_string();
+ let Some(id) = path.strip_prefix("refs/meta/redactions/") else {
+ continue;
+ };
+ let tree = super::commit_tree(&*state.objects(), tip)?;
+ if let Ok(redaction) = facet_git_tree::deserialize::<Redaction>(&tree, &*state.objects()) {
+ out.push((id.to_owned(), redaction));
+ }
+ }
+ Ok(out)
+}
crates/cli/ents-web/src/pages/toolchains.rs
@@ -1,0 +1,68 @@
+//! `GET /toolchains`, `GET /toolchains/{name}`: a custom (not generic)
+//! page family, per this crate's own top-level doc -- a toolchain's
+//! [`ents_kiln::Recipe`] needs domain-specific rendering (`Embedded` vs
+//! `Downloaded`, each with its own provenance shape) that would otherwise
+//! push a `match Recipe::Embedded { .. } => ...` into the generic
+//! reflection walk [`crate::render`] exists to keep type-agnostic. Import
+//! is not wired here: it stays a `git ents toolchain import` operation,
+//! since it takes a local directory path, not form data a browser can
+//! supply.
+
+use std::sync::Arc;
+
+use axum::extract::{Path, State};
+use ents_kiln::toolchain;
+use gix_object::{Find, Write};
+use maud::html;
+
+use crate::error::Result;
+use crate::state::AppState;
+
+/// `GET /toolchains`.
+///
+/// # Errors
+///
+/// Propagates a ref-store read failure.
+pub async fn list<O>(State(state): State<Arc<AppState<O>>>) -> Result<maud::Markup>
+where
+ O: Find + Write + Send + 'static,
+{
+ let names = toolchain::list(state.refs.as_ref())?;
+ Ok(super::layout(
+ "toolchains",
+ crate::render::string_list(&names, |name| format!("/toolchains/{name}")),
+ ))
+}
+
+/// `GET /toolchains/{name}`: the toolchain's recorded recipe and import
+/// log.
+///
+/// # Errors
+///
+/// Propagates an `ents-kiln` lookup failure (wrapped as
+/// [`crate::Error::Effect`]) if `name` has no toolchain ref.
+pub async fn show<O>(
+ State(state): State<Arc<AppState<O>>>,
+ Path(name): Path<String>,
+) -> Result<maud::Markup>
+where
+ O: Find + Write + Send + 'static,
+{
+ let (toolchain, recipe) = toolchain::view(state.refs.as_ref(), &*state.objects(), &name)?;
+ let log = toolchain::log(state.refs.as_ref(), &*state.objects(), &name)?;
+ Ok(super::layout(
+ &name,
+ html! {
+ dl {
+ dt { "name" } dd { (toolchain.name) }
+ dt { "recipe" } dd { (format!("{recipe:?}")) }
+ }
+ h2 { "import log" }
+ ul {
+ @for oid in &log {
+ li { (oid.to_string()) }
+ }
+ }
+ },
+ ))
+}
crates/cli/ents-web/src/render.rs
@@ -1,0 +1,257 @@
+//! The generic, schema-driven list/view rendering mechanism -- the UI
+//! analog of the gate executor named in this crate's development-plan
+//! row: one reflection walk over any `#[derive(Facet)]` entity's
+//! [`facet::Shape`], reused for every kernel entity this crate lists or
+//! shows, rather than one hand-written renderer per entity type.
+//!
+//! The binding rule this module exists to uphold: nothing here ever
+//! matches on *which* concrete type it was handed. [`fields`] walks
+//! whatever [`facet::Shape`] the type reflects, by field name and
+//! position, exactly the same way for [`ents_model::Member`],
+//! [`ents_model::Effect`], [`ents_model::Redaction`], or
+//! [`ents_model::Account`]. A page that genuinely needs to know it is
+//! showing a comment (to render an anchor's projected diff) or a
+//! toolchain (to render a recipe's provenance) is not a gap in this
+//! module -- it is [`crate::pages::comments`] or [`crate::pages::toolchains`]
+//! choosing a legitimate custom view instead of this generic one, exactly
+//! as this crate's development-plan row anticipates.
+
+use facet::Facet;
+use maud::{Markup, html};
+
+/// One field's name and rendered value, in declaration order.
+pub type FieldRow = (&'static str, String);
+
+/// Reflect over `value`'s [`facet::Shape`] and return one `(name, value)`
+/// pair per field, in declaration order.
+///
+/// A field's value renders via its own `Display` impl when it has one
+/// (plain text, no `Type::Foo(...)` wrapper -- what a `String` or a
+/// `MemberId` newtype gives), and falls back to `Debug` otherwise (every
+/// entity struct in `ents-model`/`ents-forge`/`ents-kiln` derives `Debug`,
+/// so an enum field like [`ents_model::MemberState`] still renders its
+/// variant name rather than an opaque placeholder). A field this crate
+/// cannot even walk as a struct (called on a non-struct `T`) renders as an
+/// empty list, not a panic -- reflection is a UI convenience, never a
+/// correctness path.
+///
+/// # Examples
+///
+/// ```
+/// use ents_model::{Member, Provenance};
+///
+/// let member = Member::new("ssh-ed25519 AAAA... jdc", Provenance::AdminRegistered);
+/// let rows = ents_web::render::fields(&member);
+/// assert_eq!(rows[0].0, "key");
+/// assert!(rows[0].1.contains("ssh-ed25519"));
+/// assert_eq!(rows[1].0, "state");
+/// assert_eq!(rows[1].1, "Active");
+/// ```
+#[must_use]
+pub fn fields<T: Facet<'static>>(value: &T) -> Vec<FieldRow> {
+ let peek = facet_reflect::Peek::new(value);
+ let Ok(structure) = peek.into_struct() else {
+ return Vec::new();
+ };
+ structure
+ .ty()
+ .fields
+ .iter()
+ .enumerate()
+ .map(|(index, field)| {
+ let rendered = structure
+ .field(index)
+ .map(render_scalar)
+ .unwrap_or_default();
+ (field.name, rendered)
+ })
+ .collect()
+}
+
+/// Render one field's [`facet_reflect::Peek`] as plain text: its own
+/// `Display` if it has one, else `Debug`, so an enum still shows a variant
+/// name instead of this crate's opaque `⟨TypeName⟩` placeholder.
+fn render_scalar(peek: facet_reflect::Peek<'_, '_>) -> String {
+ if let Some(s) = peek.as_str() {
+ return s.to_owned();
+ }
+ let displayed = format!("{peek}");
+ if displayed.starts_with('⟨') {
+ format!("{peek:?}")
+ } else {
+ displayed
+ }
+}
+
+/// A definition-list view of one entity's fields -- the generic "show"
+/// page every kernel entity this crate exposes uses.
+///
+/// # Examples
+///
+/// ```
+/// use ents_model::{Account, MemberId};
+///
+/// let account = Account { member: MemberId::new("jdc"), login: "jdc@ents.test".to_owned() };
+/// let markup = ents_web::render::view(&account);
+/// assert!(markup.into_string().contains("login"));
+/// ```
+#[must_use]
+pub fn view<T: Facet<'static>>(value: &T) -> Markup {
+ let rows = fields(value);
+ html! {
+ dl.entity-view {
+ @for (name, rendered) in &rows {
+ dt { (name) }
+ dd { (rendered) }
+ }
+ }
+ }
+}
+
+/// A table listing `rows`, one row per `(id, entity)` pair, columns taken
+/// from the entity's own reflected field names -- the generic "list" page
+/// every kernel entity this crate exposes uses.
+///
+/// `id_header` names the leading column holding each entry's key (a
+/// username, an effect name, a redaction id -- whatever names the ref this
+/// listing was read from, which is never itself a field on the entity).
+///
+/// # Examples
+///
+/// ```
+/// use ents_model::{Member, Provenance};
+///
+/// let rows = vec![("jdc".to_owned(), Member::new("key-a", Provenance::AdminRegistered))];
+/// let markup = ents_web::render::list_table(&rows, "username", |id| format!("/members/{id}"));
+/// assert!(markup.into_string().contains("jdc"));
+/// ```
+#[must_use]
+pub fn list_table<T: Facet<'static>>(
+ rows: &[(String, T)],
+ id_header: &str,
+ href_for: impl Fn(&str) -> String,
+) -> Markup {
+ let field_names: Vec<&'static str> = rows
+ .first()
+ .map(|(_, entity)| fields(entity).into_iter().map(|(name, _)| name).collect())
+ .unwrap_or_default();
+ html! {
+ table.entity-list {
+ thead {
+ tr {
+ th { (id_header) }
+ @for name in &field_names {
+ th { (name) }
+ }
+ }
+ }
+ tbody {
+ @for (id, entity) in rows {
+ tr {
+ td { a href=(href_for(id)) { (id) } }
+ @for (_, rendered) in fields(entity) {
+ td { (rendered) }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+/// A list of plain strings with no reflected entity behind them (inbox
+/// entries, toolchain names) -- deliberately not the [`fields`] mechanism,
+/// since there is no struct to reflect over, only a bare list of ids.
+#[must_use]
+pub fn string_list(rows: &[String], href_for: impl Fn(&str) -> String) -> Markup {
+ html! {
+ ul.string-list {
+ @for row in rows {
+ li { a href=(href_for(row)) { (row) } }
+ }
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ #![allow(clippy::expect_used, reason = "unit test")]
+
+ use ents_model::{Account, Effect, Member, MemberId, MemberState, Provenance, Redaction};
+ use rstest::rstest;
+
+ use super::*;
+
+ #[rstest]
+ // @relation(roots.web-agnostic, scope=function, role=Verifies)
+ fn fields_walks_every_declared_field_in_order_for_any_kernel_entity() {
+ let member = Member::new("ssh-ed25519 AAAA... jdc", Provenance::AdminRegistered);
+ let rows = fields(&member);
+ assert_eq!(
+ rows.iter().map(|(name, _)| *name).collect::<Vec<_>>(),
+ vec!["key", "state", "provenance"]
+ );
+ }
+
+ #[rstest]
+ // @relation(roots.web-agnostic, scope=function, role=Verifies)
+ fn an_enum_field_renders_its_variant_name_not_a_placeholder() {
+ let member = Member::new("key", Provenance::AdminRegistered);
+ let rows = fields(&member);
+ let (_, state) = rows
+ .iter()
+ .find(|(name, _)| *name == "state")
+ .expect("state field");
+ assert_eq!(state, "Active");
+ assert_eq!(member.state, MemberState::Active);
+ }
+
+ #[rstest]
+ #[case::member(Member::new("k", Provenance::AdminRegistered))]
+ // @relation(roots.web-agnostic, scope=function, role=Verifies)
+ fn the_same_generic_view_renders_every_entity_type(#[case] member: Member) {
+ // Same call, no type-specific branch -- this is the whole point of
+ // the generic mechanism this module exists to prove. Each call's
+ // markup is asserted non-empty and containing a field name real to
+ // that entity, so this is a render check, not a discarded call.
+ assert!(view(&member).into_string().contains("provenance"));
+ assert!(
+ view(&Effect {
+ trigger: "rev(refs/heads/main)".to_owned(),
+ toolchains: vec![],
+ run: "true".to_owned(),
+ })
+ .into_string()
+ .contains("trigger")
+ );
+ assert!(
+ view(&Redaction::new(
+ gix_hash::ObjectId::null(gix_hash::Kind::Sha1),
+ "why"
+ ))
+ .into_string()
+ .contains("reason")
+ );
+ assert!(
+ view(&Account {
+ member: MemberId::new("jdc"),
+ login: "jdc@ents.test".to_owned(),
+ })
+ .into_string()
+ .contains("login")
+ );
+ }
+
+ #[rstest]
+ // @relation(roots.web-agnostic, scope=function, role=Verifies)
+ fn list_table_derives_its_columns_from_the_first_rows_own_shape() {
+ let rows = vec![(
+ "jdc".to_owned(),
+ Member::new("key", Provenance::AdminRegistered),
+ )];
+ let markup = list_table(&rows, "username", |id| format!("/members/{id}")).into_string();
+ assert!(markup.contains("username"));
+ assert!(markup.contains("key"));
+ assert!(markup.contains("jdc"));
+ }
+}
crates/cli/ents-web/src/router.rs
@@ -1,0 +1,138 @@
+//! Wiring every page into one [`axum::Router`], plus the session/CSRF
+//! middleware every state-changing route runs behind
+//! (`roots.web-session`).
+//!
+//! [`router`] builds the [`axum::Router`] alone, with no socket ever
+//! bound -- this is what lets [`crate`]'s own tests (and, per
+//! `roots.web-agnostic`, an in-process webview embedding) drive a request
+//! through this crate's full stack via `tower::ServiceExt::oneshot`
+//! without any network transport existing at all. [`bind`]/[`serve_on`]
+//! split socket binding from serving so a caller (`git-ents`'s own `serve`
+//! command) can read back the bound port before the server starts
+//! blocking -- necessary for `--port 0` ("pick any free port") to be
+//! useful at all.
+
+use std::net::SocketAddr;
+use std::sync::Arc;
+
+use axum::Router;
+use axum::extract::{Request, State};
+use axum::http::{HeaderValue, header};
+use axum::middleware::{self, Next};
+use axum::response::Response;
+use axum::routing::get;
+use gix_object::{Find, Write};
+
+use crate::pages;
+use crate::session;
+use crate::state::AppState;
+
+/// Build the full route table, wrapped in the session middleware
+/// (`roots.web-session`).
+///
+/// Nothing here binds a socket: this `Router` is a plain, in-process
+/// `tower::Service` (`roots.web-agnostic`) -- see this module's own doc.
+// @relation(roots.web-agnostic, roots.local, scope=function)
+pub fn router<O>(state: Arc<AppState<O>>) -> Router
+where
+ O: Find + Write + Send + 'static,
+{
+ Router::new()
+ .route("/", get(pages::dashboard::show::<O>))
+ .route("/members", get(pages::members::list::<O>))
+ .route("/members/{username}", get(pages::members::show::<O>))
+ .route(
+ "/account",
+ get(pages::account::show::<O>).post(pages::account::update::<O>),
+ )
+ .route("/effects", get(pages::effects::list::<O>))
+ .route("/effects/{name}", get(pages::effects::show::<O>))
+ .route("/redactions", get(pages::redactions::list::<O>))
+ .route("/redactions/{id}", get(pages::redactions::show::<O>))
+ .route("/toolchains", get(pages::toolchains::list::<O>))
+ .route("/toolchains/{name}", get(pages::toolchains::show::<O>))
+ .route(
+ "/comments",
+ get(pages::comments::list::<O>).post(pages::comments::add::<O>),
+ )
+ .route("/comments/{id}", get(pages::comments::show::<O>))
+ .route("/inbox", get(pages::inbox::list::<O>))
+ .layer(middleware::from_fn_with_state(
+ Arc::clone(&state),
+ session_middleware::<O>,
+ ))
+ .with_state(state)
+}
+
+/// The session middleware (`roots.web-session`): recognize an existing
+/// session cookie, or mint a fresh one and set it on the response. Every
+/// handler reads the resolved [`session::Session`] via `Extension`.
+// @relation(roots.web-session, scope=function)
+async fn session_middleware<O>(
+ State(state): State<Arc<AppState<O>>>,
+ mut request: Request,
+ next: Next,
+) -> Response
+where
+ O: Find + Write + Send + 'static,
+{
+ let cookie_header = request
+ .headers()
+ .get(header::COOKIE)
+ .and_then(|value| value.to_str().ok())
+ .map(str::to_owned);
+ let existing = cookie_header
+ .as_deref()
+ .and_then(session::session_id_from_cookie_header)
+ .and_then(|id| {
+ state
+ .sessions
+ .get(id)
+ .map(|session| (id.to_owned(), session))
+ });
+
+ let (id, session, is_new) = match existing {
+ Some((id, session)) => (id, session, false),
+ None => {
+ let (id, session) = state.sessions.create();
+ (id, session, true)
+ }
+ };
+ request.extensions_mut().insert(session);
+
+ let mut response = next.run(request).await;
+ if is_new && let Ok(value) = HeaderValue::from_str(&session::set_cookie_header(&id)) {
+ response.headers_mut().append(header::SET_COOKIE, value);
+ }
+ response
+}
+
+/// Bind a loopback-or-otherwise socket for [`serve_on`], returning the
+/// listener before any request is served so a caller can read back
+/// [`std::net::TcpListener::local_addr`] (necessary for `addr`'s port `0`,
+/// "pick any free port," to be useful to a caller that must print or open
+/// the resulting URL).
+///
+/// # Errors
+///
+/// Any [`std::io::Error`] binding the socket.
+pub async fn bind(addr: SocketAddr) -> std::io::Result<tokio::net::TcpListener> {
+ tokio::net::TcpListener::bind(addr).await
+}
+
+/// Serve `state`'s router on an already-bound `listener` until the process
+/// is killed -- this crate has no shutdown signal of its own; a caller
+/// that wants graceful shutdown wraps this future with one.
+///
+/// # Errors
+///
+/// Any [`std::io::Error`] the underlying accept loop hits.
+pub async fn serve_on<O>(
+ listener: tokio::net::TcpListener,
+ state: Arc<AppState<O>>,
+) -> std::io::Result<()>
+where
+ O: Find + Write + Send + 'static,
+{
+ axum::serve(listener, router(state)).await
+}
crates/cli/ents-web/src/session.rs
@@ -1,0 +1,161 @@
+//! Hosted web sessions (`roots.web-session`): held only in this server's
+//! own process memory, never a session database or token table -- the
+//! same ban `model.account` states for authentication state generally.
+//!
+//! [`SessionStore`] is a plain `Mutex<HashMap<..>>`; there is no on-disk or
+//! external-database code path anywhere in this module for a session to
+//! reach, so "memory only" is a structural property of the type, not a
+//! configuration choice. A restarted process starts a new, empty
+//! [`SessionStore`], which is exactly why every state-changing request
+//! must additionally carry a per-session CSRF token: a stale cookie from a
+//! previous process names a session this one has never heard of, and is
+//! rejected as [`crate::Error::NoSession`] rather than silently trusted.
+
+use std::collections::HashMap;
+use std::sync::Mutex;
+
+/// The cookie name a browser carries a session id in.
+pub const COOKIE_NAME: &str = "ents_session";
+
+/// The form field (or header, for a JSON-style client) a state-changing
+/// request carries its CSRF token in.
+pub const CSRF_FIELD: &str = "csrf";
+
+/// One held session: nothing but the CSRF token it was issued.
+/// `roots.web-session` requires no more than this -- there is no login
+/// step in this phase (see `ents-web`'s crate doc for the scoping this
+/// leaves for a future account/login system), so a session's only job is
+/// letting this server recognize "the same browser that fetched the form
+/// is the one submitting it," which a bare CSRF token already proves.
+// @relation(roots.web-session, scope=file)
+#[derive(Debug, Clone)]
+pub struct Session {
+ /// The token a state-changing request must echo back.
+ pub csrf: String,
+}
+
+/// Server-memory-only session storage (`roots.web-session`).
+///
+/// # Examples
+///
+/// ```
+/// use ents_web::session::SessionStore;
+///
+/// let store = SessionStore::default();
+/// let (id, session) = store.create();
+/// assert_eq!(store.get(&id).expect("just created").csrf, session.csrf);
+/// assert!(store.get("no-such-id").is_none());
+/// ```
+// @relation(roots.web-session, scope=file)
+#[derive(Default)]
+pub struct SessionStore {
+ sessions: Mutex<HashMap<String, Session>>,
+}
+
+impl SessionStore {
+ /// Mint a new session with a fresh random id and CSRF token, and hold
+ /// it in memory.
+ ///
+ /// # Panics
+ ///
+ /// Never in practice: [`getrandom::fill`] only fails if the platform's
+ /// randomness source itself is unavailable, which every supported
+ /// target has.
+ #[must_use]
+ pub fn create(&self) -> (String, Session) {
+ let id = random_token();
+ let session = Session {
+ csrf: random_token(),
+ };
+ #[expect(
+ clippy::unwrap_used,
+ reason = "a poisoned mutex means an earlier panic already unwound this process; \
+ there is no meaningful recovery for a session store, only a fresh restart"
+ )]
+ self.sessions
+ .lock()
+ .unwrap()
+ .insert(id.clone(), session.clone());
+ (id, session)
+ }
+
+ /// Look up a held session by id.
+ #[must_use]
+ pub fn get(&self, id: &str) -> Option<Session> {
+ #[expect(clippy::unwrap_used, reason = "see Self::create's identical reasoning")]
+ self.sessions.lock().unwrap().get(id).cloned()
+ }
+}
+
+/// A random, URL-safe token: 32 hex characters from 16 random bytes.
+fn random_token() -> String {
+ let mut bytes = [0u8; 16];
+ #[expect(
+ clippy::expect_used,
+ reason = "getrandom only fails when the platform has no randomness source at all, which \
+ every target this crate ships to provides"
+ )]
+ getrandom::fill(&mut bytes).expect("platform randomness source is available");
+ bytes.iter().map(|b| format!("{b:02x}")).collect()
+}
+
+/// Parse `Cookie:` header bytes for [`COOKIE_NAME`]'s value.
+#[must_use]
+pub fn session_id_from_cookie_header(header: &str) -> Option<&str> {
+ header.split(';').find_map(|pair| {
+ let (name, value) = pair.trim().split_once('=')?;
+ (name == COOKIE_NAME).then_some(value)
+ })
+}
+
+/// Render a `Set-Cookie` header value for `id` -- `HttpOnly` and
+/// `SameSite=Strict` since this cookie is never read by page script and
+/// only ever needs to accompany same-site requests (`roots.web-session`'s
+/// CSRF requirement is the belt to this cookie's suspenders, not a
+/// replacement for it: `SameSite=Strict` alone would already block a
+/// cross-site POST, but a network intermediary or a future relaxation of
+/// that attribute must not silently remove the protection).
+#[must_use]
+pub fn set_cookie_header(id: &str) -> String {
+ format!("{COOKIE_NAME}={id}; Path=/; HttpOnly; SameSite=Strict")
+}
+
+#[cfg(test)]
+mod tests {
+ #![allow(clippy::expect_used, reason = "unit test")]
+
+ use rstest::rstest;
+
+ use super::*;
+
+ #[rstest]
+ // @relation(roots.web-session, scope=function, role=Verifies)
+ fn a_fresh_store_never_recognizes_a_foreign_session_id() {
+ let a = SessionStore::default();
+ let b = SessionStore::default();
+ let (id, _) = a.create();
+ assert!(
+ b.get(&id).is_none(),
+ "a session minted by one store must not be recognized by another -- there is no \
+ shared backing store for either to consult"
+ );
+ }
+
+ #[rstest]
+ // @relation(roots.web-session, scope=function, role=Verifies)
+ fn cookie_header_round_trips_the_session_id() {
+ let header = set_cookie_header("abc123");
+ assert!(header.contains("HttpOnly"));
+ let raw_cookie = header.split(';').next().expect("at least one segment");
+ assert_eq!(session_id_from_cookie_header(raw_cookie), Some("abc123"));
+ }
+
+ #[rstest]
+ // @relation(roots.web-session, scope=function, role=Verifies)
+ fn two_sessions_never_share_a_csrf_token() {
+ let store = SessionStore::default();
+ let (_, first) = store.create();
+ let (_, second) = store.create();
+ assert_ne!(first.csrf, second.csrf);
+ }
+}
crates/cli/ents-web/src/state.rs
@@ -1,0 +1,107 @@
+//! The web frontend's own handle onto the four composition-root seams
+//! (`roots.composition`), generic over only the object store: `refs` and
+//! `events` are already used as trait objects everywhere in this codebase
+//! (`git_ents::root::LocalRoot` passes `&root.refs` where `&dyn RefStore`
+//! is expected; `ents_forge::comment::add` takes `events: &dyn
+//! ents_receive::EventSink` directly), so [`AppState`] holds them boxed
+//! rather than introducing a type parameter this crate has no other use
+//! for. The object store stays a type parameter `O` because every mutation
+//! path (`ents_receive::propose_entity`) takes it as `&(impl
+//! gix_object::Find + gix_object::Write)`, generic, never `dyn` --
+//! matching that established shape rather than inventing a private
+//! object-store trait (`arch.no-object-store-trait`).
+//!
+//! `objects` is held behind a [`std::sync::Mutex`] rather than bare `O`:
+//! axum requires its `State` to be `Sync` (so it can be shared across
+//! however many worker tasks accept connections), but neither this
+//! crate's real composition-root object store nor its test fixture
+//! (`ents_testutil::ObjectStore`, used by every test in this crate) is
+//! `Sync` on its own -- the fixture's internal `RefCell` makes that
+//! concrete, but the same caution applies to any future object-store
+//! implementation this crate is handed, since nothing about
+//! `gix_object::Find`/`Write` requires an implementation to be safe for
+//! concurrent access. Serializing access behind one mutex is the right
+//! choice for this crate regardless: a web admin UI's request volume is
+//! not a throughput target `roots.adoc` names anywhere.
+
+use std::path::PathBuf;
+use std::sync::Mutex;
+
+use ents_receive::{EventSink, Mode};
+use gix_ref_store::RefStore;
+
+use crate::identity::SigningIdentity;
+use crate::session::SessionStore;
+
+/// Everything a page handler needs: the four composition-root seams, the
+/// gate policy in force, the repository's working-tree path (comment
+/// anchoring resolves paths against it), and the in-memory session store
+/// (`roots.web-session`).
+///
+/// Built once per `ents_web::serve`/`ents_web::router` call, by whichever
+/// composition root is wiring this crate in -- never constructed inside a
+/// page handler itself (`roots.config-isolation`'s spirit: every seam
+/// arrives already chosen).
+pub struct AppState<O> {
+ /// The ref store, as the same trait-object shape every mutation
+ /// primitive in this codebase already takes it.
+ pub refs: Box<dyn RefStore>,
+ /// The object store, mutex-serialized (see this module's own doc for
+ /// why). A type parameter, not `dyn`, so every existing
+ /// `propose_entity`/`comment::add`/`toolchain::import` call compiles
+ /// unchanged against a lock guard's deref.
+ objects: Mutex<O>,
+ /// The event sink obligations are enqueued to on a push
+ /// (`receive.event-sink`) -- a local deployment injects a null sink
+ /// (`roots.local`), matching `git ents`'s own CLI commands.
+ pub events: Box<dyn EventSink>,
+ /// The gate policy this deployment runs under (`roots.local`:
+ /// advisory; a future hosted `ents-web` wiring: mandatory).
+ pub mode: Mode,
+ /// The signing identity every mutation page signs on behalf of
+ /// (`roots.web-signing`, `roots.web-agnostic`).
+ pub identity: Box<dyn SigningIdentity>,
+ /// The repository's own path, for anchoring operations
+ /// (`ents_forge::comment`) that need to open the working tree
+ /// directly.
+ pub path: PathBuf,
+ /// In-memory web sessions (`roots.web-session`).
+ pub sessions: SessionStore,
+}
+
+impl<O> AppState<O> {
+ /// Build a state from already-wired seams -- the one constructor every
+ /// composition root calls, and the only place a fresh
+ /// [`SessionStore`] is created.
+ pub fn new(
+ refs: Box<dyn RefStore>,
+ objects: O,
+ events: Box<dyn EventSink>,
+ mode: Mode,
+ identity: Box<dyn SigningIdentity>,
+ path: PathBuf,
+ ) -> Self {
+ Self {
+ refs,
+ objects: Mutex::new(objects),
+ events,
+ mode,
+ identity,
+ path,
+ sessions: SessionStore::default(),
+ }
+ }
+
+ /// Lock the object store for the duration of one request.
+ ///
+ /// Poisoning recovers rather than propagating (mirrors
+ /// `SessionStore`'s identical reasoning): an earlier request
+ /// panicking mid-write already unwound that request's own response;
+ /// refusing every subsequent request forever would be strictly worse
+ /// than reusing the store as-is.
+ pub fn objects(&self) -> std::sync::MutexGuard<'_, O> {
+ self.objects
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner)
+ }
+}
crates/cli/ents-web/tests/router.rs
@@ -1,0 +1,362 @@
+//! Integration coverage for `docs/spec/roots.adoc`'s web-frontend
+//! requirements, driven entirely through [`tower::ServiceExt::oneshot`]
+//! against [`ents_web::router`] -- no socket is ever bound anywhere in
+//! this file, which is itself part of the proof for `roots.web-agnostic`:
+//! every one of these requests is exercised the same way an in-process
+//! webview embedding would drive them.
+#![allow(clippy::expect_used, reason = "integration test")]
+#![allow(clippy::unwrap_used, reason = "integration test")]
+
+use std::sync::Arc;
+
+use axum::body::Body;
+use axum::http::{Request, StatusCode, header};
+use ents_model::{Account, MemberId};
+use ents_receive::{Mode, NullEventSink};
+use ents_testutil::{Keypair, MemRefStore, ObjectStore};
+use ents_web::identity::SigningIdentity;
+use ents_web::state::AppState;
+use gix::bstr::ByteSlice as _;
+use http_body_util::BodyExt as _;
+use tower::ServiceExt as _;
+
+/// A fixture [`SigningIdentity`] wrapping a deterministic test key, named
+/// so a test can tell two different injected identities apart by their
+/// commit author name alone.
+struct FixtureIdentity {
+ name: &'static str,
+ key: Keypair,
+}
+
+impl SigningIdentity for FixtureIdentity {
+ fn actor(&self) -> gix::actor::Signature {
+ gix::actor::Signature {
+ name: self.name.into(),
+ email: format!("{}@ents.test", self.name).into(),
+ time: gix::date::Time {
+ seconds: 1_000,
+ offset: 0,
+ },
+ }
+ }
+
+ fn sign(&self, payload: &[u8]) -> String {
+ self.key.sign(payload)
+ }
+
+ fn public_openssh(&self) -> String {
+ self.key.public_openssh()
+ }
+}
+
+fn build_state(identity: FixtureIdentity) -> Arc<AppState<ObjectStore>> {
+ Arc::new(AppState::new(
+ Box::new(MemRefStore::default()),
+ ObjectStore::default(),
+ Box::new(NullEventSink),
+ Mode::Advisory,
+ Box::new(identity),
+ std::env::temp_dir(),
+ ))
+}
+
+/// `roots.local`: this crate's route table never exposes git's own
+/// smart-HTTP transport -- a request that would name it (`info/refs` with
+/// a `service` query, exactly the URL stock `git clone`/`git fetch` sends
+/// a dumb or smart HTTP backend) falls through to axum's ordinary 404,
+/// not a git wire-protocol response.
+#[tokio::test]
+// @relation(roots.local, scope=function, role=Verifies)
+async fn smart_http_transport_is_never_exposed() {
+ let state = build_state(FixtureIdentity {
+ name: "local-user",
+ key: Keypair::from_seed(1),
+ });
+ let router = ents_web::router(state);
+
+ let response = router
+ .oneshot(
+ Request::get("/info/refs?service=git-upload-pack")
+ .body(Body::empty())
+ .expect("request"),
+ )
+ .await
+ .expect("in-process call");
+ assert_eq!(response.status(), StatusCode::NOT_FOUND);
+}
+
+/// `roots.web-agnostic`: the dashboard actually renders real content
+/// in-process, with no socket bound anywhere in this test -- reading the
+/// body back (rather than only checking the status) is what makes this
+/// test more than a routing smoke check.
+#[tokio::test]
+// @relation(roots.web-agnostic, scope=function, role=Verifies)
+async fn dashboard_renders_in_process_with_no_socket_bound() {
+ let state = build_state(FixtureIdentity {
+ name: "local-user",
+ key: Keypair::from_seed(1),
+ });
+ let router = ents_web::router(state);
+
+ let response = router
+ .oneshot(Request::get("/").body(Body::empty()).expect("request"))
+ .await
+ .expect("in-process call");
+ assert_eq!(response.status(), StatusCode::OK);
+ let body = response
+ .into_body()
+ .collect()
+ .await
+ .expect("body")
+ .to_bytes();
+ let body = String::from_utf8(body.to_vec()).expect("utf8 html");
+ assert!(body.contains("members"));
+ assert!(body.contains("toolchains"));
+}
+
+/// `roots.web-session`: a state-changing request with no CSRF token at
+/// all is a bad request (axum's own `Form` rejection); one with the wrong
+/// token is refused by this crate's own check; the session cookie a `GET`
+/// mints is required to learn the right one at all.
+#[tokio::test]
+// @relation(roots.web-session, scope=function, role=Verifies)
+async fn csrf_is_required_and_checked_on_every_state_changing_request() {
+ let state = build_state(FixtureIdentity {
+ name: "local-user",
+ key: Keypair::from_seed(1),
+ });
+ let router = ents_web::router(Arc::clone(&state));
+
+ // No CSRF field at all in the POST body.
+ let response = router
+ .clone()
+ .oneshot(
+ Request::post("/account")
+ .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
+ .body(Body::from("member=jdc&login=jdc@ents.test"))
+ .expect("request"),
+ )
+ .await
+ .expect("in-process call");
+ assert!(
+ !response.status().is_success(),
+ "a POST with no csrf field at all must not succeed"
+ );
+
+ // A GET establishes a session; extract its id from Set-Cookie, then
+ // read the matching CSRF token directly out of the (in-memory-only)
+ // session store this test built.
+ let get_response = router
+ .clone()
+ .oneshot(
+ Request::get("/account")
+ .body(Body::empty())
+ .expect("request"),
+ )
+ .await
+ .expect("in-process call");
+ let cookie = get_response
+ .headers()
+ .get(header::SET_COOKIE)
+ .expect("a fresh GET always mints a session cookie")
+ .to_str()
+ .expect("ascii")
+ .to_owned();
+ let session_id = cookie
+ .split(';')
+ .next()
+ .expect("at least one segment")
+ .split_once('=')
+ .expect("name=value")
+ .1
+ .to_owned();
+ let csrf = state
+ .sessions
+ .get(&session_id)
+ .expect("the session this cookie names is held in this server's own memory")
+ .csrf;
+
+ // The wrong token is refused.
+ let response = router
+ .clone()
+ .oneshot(
+ Request::post("/account")
+ .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
+ .header(header::COOKIE, cookie.clone())
+ .body(Body::from(
+ "member=jdc&login=jdc@ents.test&csrf=not-the-token",
+ ))
+ .expect("request"),
+ )
+ .await
+ .expect("in-process call");
+ assert_eq!(response.status(), StatusCode::BAD_REQUEST);
+
+ // The right token, carried by the same session cookie, succeeds.
+ let response = router
+ .clone()
+ .oneshot(
+ Request::post("/account")
+ .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
+ .header(header::COOKIE, cookie)
+ .body(Body::from(format!(
+ "member=jdc&login=jdc@ents.test&csrf={csrf}"
+ )))
+ .expect("request"),
+ )
+ .await
+ .expect("in-process call");
+ assert!(
+ response.status().is_redirection(),
+ "{:?}",
+ response.status()
+ );
+}
+
+/// `roots.web-session`: a session is recognized across requests that
+/// carry its cookie (no fresh `Set-Cookie` reissued), and is held only in
+/// this server's own process memory -- a second, independently built
+/// state/router pair (standing in for a second process) never recognizes
+/// a cookie the first one minted.
+#[tokio::test]
+// @relation(roots.web-session, scope=function, role=Verifies)
+async fn a_session_is_recognized_across_requests_but_never_across_servers() {
+ let state_a = build_state(FixtureIdentity {
+ name: "a",
+ key: Keypair::from_seed(1),
+ });
+ let router_a = ents_web::router(Arc::clone(&state_a));
+
+ let first = router_a
+ .clone()
+ .oneshot(Request::get("/").body(Body::empty()).expect("request"))
+ .await
+ .expect("in-process call");
+ let cookie = first
+ .headers()
+ .get(header::SET_COOKIE)
+ .expect("first request mints a session")
+ .clone();
+
+ let second = router_a
+ .clone()
+ .oneshot(
+ Request::get("/")
+ .header(header::COOKIE, cookie.clone())
+ .body(Body::empty())
+ .expect("request"),
+ )
+ .await
+ .expect("in-process call");
+ assert!(
+ second.headers().get(header::SET_COOKIE).is_none(),
+ "a recognized session must not be re-minted"
+ );
+
+ // A second server (fresh in-memory session store) never recognizes
+ // the first server's cookie.
+ let state_b = build_state(FixtureIdentity {
+ name: "b",
+ key: Keypair::from_seed(2),
+ });
+ let router_b = ents_web::router(state_b);
+ let third = router_b
+ .oneshot(
+ Request::get("/")
+ .header(header::COOKIE, cookie)
+ .body(Body::empty())
+ .expect("request"),
+ )
+ .await
+ .expect("in-process call");
+ assert!(
+ third.headers().get(header::SET_COOKIE).is_some(),
+ "a foreign session id must be treated as absent, minting a fresh one"
+ );
+}
+
+/// `roots.web-signing`, `roots.web-agnostic`: the identical page handler,
+/// reached through the identical route, signs each request's mutation
+/// commit with whichever [`SigningIdentity`] its own composition root
+/// injected -- never a fixed or shared one. This is the crate-level proof
+/// the development plan assigns this phase; wiring an actual hosted
+/// server-key identity behind `git-ents-server` is phase 8's job (see
+/// this crate's own top-level doc).
+#[tokio::test]
+// @relation(roots.web-signing, roots.web-agnostic, scope=function, role=Verifies)
+async fn each_request_is_signed_by_its_own_injected_identity_never_a_shared_one() {
+ for (name, seed) in [("local-style", 11u8), ("hosted-style", 22u8)] {
+ let state = build_state(FixtureIdentity {
+ name,
+ key: Keypair::from_seed(seed),
+ });
+ let router = ents_web::router(Arc::clone(&state));
+
+ let get_response = router
+ .clone()
+ .oneshot(
+ Request::get("/account")
+ .body(Body::empty())
+ .expect("request"),
+ )
+ .await
+ .expect("in-process call");
+ let cookie = get_response
+ .headers()
+ .get(header::SET_COOKIE)
+ .expect("session")
+ .to_str()
+ .expect("ascii")
+ .to_owned();
+ let session_id = cookie
+ .split(';')
+ .next()
+ .expect("segment")
+ .split_once('=')
+ .expect("name=value")
+ .1
+ .to_owned();
+ let csrf = state.sessions.get(&session_id).expect("session").csrf;
+
+ let response = router
+ .oneshot(
+ Request::post("/account")
+ .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
+ .header(header::COOKIE, cookie)
+ .body(Body::from(format!(
+ "member=jdc&login=jdc@ents.test&csrf={csrf}"
+ )))
+ .expect("request"),
+ )
+ .await
+ .expect("in-process call");
+ assert!(response.status().is_redirection());
+
+ // Read the commit this request wrote back directly (this test's
+ // own retained `state` handle, not a second connection) and
+ // confirm its author is exactly this iteration's own identity.
+ let name_ref: gix::refs::FullName = ents_model::namespace::ACCOUNT_REF
+ .try_into()
+ .expect("valid");
+ let tip = state
+ .refs
+ .get(name_ref.as_ref())
+ .expect("readable")
+ .expect("account was just written");
+ let mut buf = Vec::new();
+ let objects = state.objects();
+ let data = gix_object::Find::try_find(&*objects, &tip, &mut buf)
+ .expect("read")
+ .expect("present");
+ let commit = gix_object::CommitRef::from_bytes(data.data, tip.kind()).expect("commit");
+ assert!(
+ commit.author.to_str_lossy().contains(name),
+ "commit author {:?} must carry this iteration's own identity name {name:?}",
+ commit.author.to_str_lossy()
+ );
+
+ let tree = commit.tree();
+ let account: Account = facet_git_tree::deserialize(&tree, &*objects).expect("typed tree");
+ assert_eq!(account.member, MemberId::new("jdc"));
+ }
+}
crates/cli/git-ents/src/commands/serve.rs
@@ -1,0 +1,159 @@
+//! `git ents serve`: reuse [`LocalRoot`]'s existing wiring and add only
+//! the `ents-web` HTTP frontend, bound to loopback (`roots.local`).
+//!
+//! `roots.local` is explicit that this command MUST reuse the local
+//! root's own seams rather than construct a second one, and MUST NOT
+//! expose git's smart-HTTP transport in any form. This module upholds
+//! both: [`build_state`] is handed an already-open [`LocalRoot`] (never
+//! opens its own), and adds nothing but `ents_web::router()`'s own route
+//! table -- which carries no `/info/refs` or `git-upload-pack` surface at
+//! all (see `ents-web`'s own test coverage for that half).
+//!
+//! # Signing identity (`roots.web-signing`)
+//!
+//! `LocalIdentity` is the one place this crate bridges its own
+//! [`Signer`] to [`ents_web::identity::SigningIdentity`]: the local root
+//! signs every web edit with the user's own member key, resolved exactly
+//! as every other mutation command resolves it (`--key`, else
+//! `user.signingkey`, else the default `~/.ssh/id_ed25519`) — no
+//! server-key indirection exists anywhere in this module, which is
+//! exactly what keeps `roots.web-signing`'s hosted-only indirection from
+//! leaking into the local root.
+
+use std::net::{IpAddr, Ipv4Addr, SocketAddr};
+use std::path::PathBuf;
+use std::sync::Arc;
+
+use ents_web::identity::SigningIdentity;
+use ents_web::state::AppState;
+
+use super::actor;
+use crate::error::{Error, Result};
+use crate::root::LocalRoot;
+use crate::sign::Signer;
+
+/// Bridges [`Signer`] to [`SigningIdentity`]: the local root's half of
+/// `roots.web-signing`'s indirection (the user's own key, captured once
+/// at `serve` startup rather than re-resolved per request).
+// @relation(roots.web-signing, scope=file)
+struct LocalIdentity {
+ signer: Signer,
+ actor: gix::actor::Signature,
+}
+
+impl SigningIdentity for LocalIdentity {
+ fn actor(&self) -> gix::actor::Signature {
+ self.actor.clone()
+ }
+
+ fn sign(&self, payload: &[u8]) -> String {
+ self.signer.sign(payload)
+ }
+
+ fn public_openssh(&self) -> String {
+ self.signer.public_openssh()
+ }
+}
+
+/// The loopback address `git ents serve` binds -- `roots.local` forbids
+/// this command from exposing anything but loopback, so there is no
+/// `--host` flag anywhere in [`crate::cli`] to override it.
+// @relation(roots.local, scope=function)
+fn loopback_addr(port: u16) -> SocketAddr {
+ SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port)
+}
+
+/// Build the [`AppState`] `git ents serve` runs, from an already-open
+/// [`LocalRoot`] -- the one seam-wiring step `roots.composition` allows,
+/// and the only place this command touches `root`'s fields at all.
+///
+/// Split out from [`run`] so tests can drive the resulting state through
+/// `ents_web::router()` directly (via `tower::ServiceExt::oneshot`, per
+/// `roots.web-agnostic`) without binding a socket or blocking.
+///
+/// # Errors
+///
+/// Propagates a signing-key resolution failure ([`crate::sign::Signer`]).
+// @relation(roots.local, roots.composition, scope=function)
+pub fn build_state(
+ root: LocalRoot,
+ key: Option<PathBuf>,
+) -> Result<Arc<AppState<crate::root::Objects>>> {
+ let signer = super::signer(&root, key)?;
+ let identity = LocalIdentity {
+ actor: actor(&signer),
+ signer,
+ };
+ let mode = root.mode();
+ let LocalRoot {
+ path,
+ refs,
+ objects,
+ events,
+ executor: _,
+ } = root;
+ Ok(Arc::new(AppState::new(
+ Box::new(refs),
+ objects,
+ Box::new(events),
+ mode,
+ Box::new(identity),
+ path,
+ )))
+}
+
+/// Run `git ents serve`: bind loopback and block, serving the web UI
+/// until the process is killed.
+///
+/// # Errors
+///
+/// Propagates [`build_state`]'s own errors, or an [`Error::Io`] binding
+/// the loopback socket or constructing the async runtime.
+// @relation(roots.local, scope=function)
+pub fn run(
+ root: LocalRoot,
+ port: Option<u16>,
+ key: Option<PathBuf>,
+ mut report: impl std::io::Write,
+) -> Result<()> {
+ let state = build_state(root, key)?;
+ let addr = loopback_addr(port.unwrap_or(4880));
+
+ let runtime = tokio::runtime::Runtime::new().map_err(|source| Error::Io {
+ path: PathBuf::from("<tokio runtime>"),
+ source,
+ })?;
+ runtime.block_on(async move {
+ let listener = ents_web::bind(addr).await.map_err(|source| Error::Io {
+ path: PathBuf::from(addr.to_string()),
+ source,
+ })?;
+ let bound = listener.local_addr().map_err(|source| Error::Io {
+ path: PathBuf::from(addr.to_string()),
+ source,
+ })?;
+ let _ = writeln!(report, "listening on http://{bound}");
+ ents_web::serve_on(listener, state)
+ .await
+ .map_err(|source| Error::Io {
+ path: PathBuf::from(addr.to_string()),
+ source,
+ })
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ #![allow(clippy::expect_used, reason = "unit test")]
+
+ use rstest::rstest;
+
+ use super::*;
+
+ #[rstest]
+ // @relation(roots.local, scope=function, role=Verifies)
+ fn serve_only_ever_binds_loopback() {
+ assert_eq!(loopback_addr(4880).ip(), IpAddr::V4(Ipv4Addr::LOCALHOST));
+ assert_eq!(loopback_addr(0).ip(), IpAddr::V4(Ipv4Addr::LOCALHOST));
+ }
+}
crates/cli/git-ents/tests/serve.rs
@@ -1,0 +1,64 @@
+//! Integration coverage for `git ents serve`'s wiring (`roots.local`):
+//! [`git_ents::commands::serve::build_state`] reuses a real
+//! [`LocalRoot`]'s own seams (the same loose-ref `RefStore` and odb every
+//! other porcelain command uses), signs with the local user's own key
+//! (`roots.web-signing`), and the resulting `ents-web` router carries no
+//! git smart-HTTP surface — all driven in-process via
+//! `tower::ServiceExt::oneshot`, no socket ever bound (`roots.web-agnostic`).
+#![allow(clippy::expect_used, reason = "integration test")]
+
+mod common;
+
+use axum::body::Body;
+use axum::http::{Request, StatusCode};
+use git_ents::commands::members;
+use git_ents::root::LocalRoot;
+use tower::ServiceExt as _;
+
+/// `roots.local`: `git ents serve`'s state is built from an already-open
+/// [`LocalRoot`] (never a second store), and the router it drives exposes
+/// only the web UI — no `/info/refs`, no git wire protocol.
+#[tokio::test]
+// @relation(roots.local, roots.composition, scope=function, role=Verifies)
+async fn serve_reuses_the_local_root_and_exposes_no_git_transport() {
+ let fixture = common::Fixture::new(1);
+ let root = LocalRoot::open(fixture.path()).expect("opens");
+ members::add(&root, "jdc", None, Some(fixture.key_path.clone())).expect("bootstrap");
+
+ let root = LocalRoot::open(fixture.path()).expect("reopen for serve");
+ let state = git_ents::commands::serve::build_state(root, Some(fixture.key_path.clone()))
+ .expect("builds state from the local root");
+ let router = ents_web::router(state);
+
+ let dashboard = router
+ .clone()
+ .oneshot(Request::get("/").body(Body::empty()).expect("request"))
+ .await
+ .expect("in-process call");
+ assert_eq!(dashboard.status(), StatusCode::OK);
+
+ let members_page = router
+ .clone()
+ .oneshot(
+ Request::get("/members")
+ .body(Body::empty())
+ .expect("request"),
+ )
+ .await
+ .expect("in-process call");
+ assert_eq!(members_page.status(), StatusCode::OK);
+
+ let smart_http = router
+ .oneshot(
+ Request::get("/info/refs?service=git-upload-pack")
+ .body(Body::empty())
+ .expect("request"),
+ )
+ .await
+ .expect("in-process call");
+ assert_eq!(
+ smart_http.status(),
+ StatusCode::NOT_FOUND,
+ "git ents serve must never expose git's own smart-HTTP transport"
+ );
+}