crates/cli/ents-web/src/state.rs
state.rshistorycomment on this file
| 1 | //! The web frontend's own handle onto the four composition-root seams |
| 2 | //! (`roots.composition`), generic over only the object store: `refs` and |
| 3 | //! `events` are already used as trait objects everywhere in this codebase |
| 4 | //! (`git_ents::root::LocalRoot` passes `&root.refs` where `&dyn RefStore` |
| 5 | //! is expected; `ents_forge::comment::add` takes `events: &dyn |
| 6 | //! ents_receive::EventSink` directly), so [`AppState`] holds them boxed |
| 7 | //! rather than introducing a type parameter this crate has no other use |
| 8 | //! for. The object store stays a type parameter `O` because every mutation |
| 9 | //! path (`ents_receive::propose_entity`) takes it as `&(impl |
| 10 | //! gix_object::Find + gix_object::Write)`, generic, never `dyn` -- |
| 11 | //! matching that established shape rather than inventing a private |
| 12 | //! object-store trait (`arch.no-object-store-trait`). |
| 13 | //! |
| 14 | //! `objects` is held behind a [`std::sync::Mutex`] rather than bare `O`: |
| 15 | //! axum requires its `State` to be `Sync` (so it can be shared across |
| 16 | //! however many worker tasks accept connections), but neither this |
| 17 | //! crate's real composition-root object store nor its test fixture |
| 18 | //! (`ents_testutil::ObjectStore`, used by every test in this crate) is |
| 19 | //! `Sync` on its own -- the fixture's internal `RefCell` makes that |
| 20 | //! concrete, but the same caution applies to any future object-store |
| 21 | //! implementation this crate is handed, since nothing about |
| 22 | //! `gix_object::Find`/`Write` requires an implementation to be safe for |
| 23 | //! concurrent access. Serializing access behind one mutex is the right |
| 24 | //! choice for this crate regardless: a web admin UI's request volume is |
| 25 | //! not a throughput target `roots.adoc` names anywhere. |
| 26 | |
| 27 | use std::path::PathBuf; |
| 28 | use std::sync::Mutex; |
| 29 | |
| 30 | use ents_receive::{EventSink, Mode}; |
| 31 | use gix_ref_store::RefStore; |
| 32 | |
| 33 | use crate::auth::ChallengeStore; |
| 34 | use crate::identity::SigningIdentity; |
| 35 | use crate::session::SessionStore; |
| 36 | |
| 37 | /// Who may mutate through this deployment's web UI — injected by the |
| 38 | /// composition root, exactly as the signing identity is |
| 39 | /// (`roots.web-agnostic`): this crate branches on the injected policy's |
| 40 | /// value, never on where it is running (`arch.no-hosted-branch`'s |
| 41 | /// spirit). |
| 42 | // @relation(roots.web-signin, scope=type) |
| 43 | pub enum AccessPolicy { |
| 44 | /// Every session mutates as the injected identity — the local root |
| 45 | /// (`roots.local`), where the operator's own key *is* the identity |
| 46 | /// and no sign-in surface exists (`roots.web-signin`). |
| 47 | Trusted, |
| 48 | /// Anonymous sessions browse read-only; a mutation requires a |
| 49 | /// session signed in as an enrolled, active member |
| 50 | /// (`roots.web-signin`) — the hosted root. |
| 51 | SignInRequired(Realm), |
| 52 | } |
| 53 | |
| 54 | /// What a sign-in-required deployment knows about itself: the canonical |
| 55 | /// external host bound into every challenge payload |
| 56 | /// ([`crate::auth::challenge_payload`]), and the outstanding challenges. |
| 57 | pub struct Realm { |
| 58 | /// The host a member addresses this deployment as, e.g. |
| 59 | /// `git.ents.cloud` — a signature is bound to it, so one minted for |
| 60 | /// this realm verifies nowhere else (`roots.web-signin`). |
| 61 | pub host: String, |
| 62 | /// Outstanding sign-in challenges, memory-only like the sessions. |
| 63 | pub challenges: ChallengeStore, |
| 64 | } |
| 65 | |
| 66 | /// Everything a page handler needs: the four composition-root seams, the |
| 67 | /// gate policy in force, the repository's working-tree path (comment |
| 68 | /// anchoring resolves paths against it), and the in-memory session store |
| 69 | /// (`roots.web-session`). |
| 70 | /// |
| 71 | /// Built once per `ents_web::serve`/`ents_web::router` call, by whichever |
| 72 | /// composition root is wiring this crate in -- never constructed inside a |
| 73 | /// page handler itself (`roots.config-isolation`'s spirit: every seam |
| 74 | /// arrives already chosen). |
| 75 | pub struct AppState<O> { |
| 76 | /// The ref store, as the same trait-object shape every mutation |
| 77 | /// primitive in this codebase already takes it. |
| 78 | pub refs: Box<dyn RefStore>, |
| 79 | /// The object store, mutex-serialized (see this module's own doc for |
| 80 | /// why). A type parameter, not `dyn`, so every existing |
| 81 | /// `propose_entity`/`comment::add`/`toolchain::import` call compiles |
| 82 | /// unchanged against a lock guard's deref. |
| 83 | objects: Mutex<O>, |
| 84 | /// The event sink obligations are enqueued to on a push |
| 85 | /// (`receive.event-sink`) -- a local deployment injects a null sink |
| 86 | /// (`roots.local`), matching `git ents`'s own CLI commands. |
| 87 | pub events: Box<dyn EventSink>, |
| 88 | /// The gate policy this deployment runs under (`roots.local`: |
| 89 | /// advisory; a future hosted `ents-web` wiring: mandatory). |
| 90 | pub mode: Mode, |
| 91 | /// The signing identity every mutation page signs on behalf of |
| 92 | /// (`roots.web-signing`, `roots.web-agnostic`). |
| 93 | pub identity: Box<dyn SigningIdentity>, |
| 94 | /// The repository's own path, for anchoring operations |
| 95 | /// (`ents_forge::comment`) that need to open the working tree |
| 96 | /// directly. |
| 97 | pub path: PathBuf, |
| 98 | /// In-memory web sessions (`roots.web-session`). |
| 99 | pub sessions: SessionStore, |
| 100 | /// Who may mutate here (`roots.web-signin`): [`AccessPolicy::Trusted`] |
| 101 | /// unless the composition root said otherwise via |
| 102 | /// [`AppState::with_access`]. |
| 103 | pub access: AccessPolicy, |
| 104 | } |
| 105 | |
| 106 | impl<O> AppState<O> { |
| 107 | /// Build a state from already-wired seams -- the one constructor every |
| 108 | /// composition root calls, and the only place a fresh |
| 109 | /// [`SessionStore`] is created. |
| 110 | pub fn new( |
| 111 | refs: Box<dyn RefStore>, |
| 112 | objects: O, |
| 113 | events: Box<dyn EventSink>, |
| 114 | mode: Mode, |
| 115 | identity: Box<dyn SigningIdentity>, |
| 116 | path: PathBuf, |
| 117 | ) -> Self { |
| 118 | Self { |
| 119 | refs, |
| 120 | objects: Mutex::new(objects), |
| 121 | events, |
| 122 | mode, |
| 123 | identity, |
| 124 | path, |
| 125 | sessions: SessionStore::default(), |
| 126 | access: AccessPolicy::Trusted, |
| 127 | } |
| 128 | } |
| 129 | |
| 130 | /// Replace the default [`AccessPolicy::Trusted`] — the hosted |
| 131 | /// composition root's one extra wiring step |
| 132 | /// (`roots.single-node-hosted`, `roots.web-signin`). A consuming |
| 133 | /// builder rather than a constructor parameter so every existing |
| 134 | /// `new` caller (every `Trusted` deployment and test fixture) stays |
| 135 | /// untouched. |
| 136 | #[must_use] |
| 137 | pub fn with_access(mut self, access: AccessPolicy) -> Self { |
| 138 | self.access = access; |
| 139 | self |
| 140 | } |
| 141 | |
| 142 | /// Lock the object store for the duration of one request. |
| 143 | /// |
| 144 | /// Poisoning recovers rather than propagating (mirrors |
| 145 | /// `SessionStore`'s identical reasoning): an earlier request |
| 146 | /// panicking mid-write already unwound that request's own response; |
| 147 | /// refusing every subsequent request forever would be strictly worse |
| 148 | /// than reusing the store as-is. |
| 149 | pub fn objects(&self) -> std::sync::MutexGuard<'_, O> { |
| 150 | self.objects |
| 151 | .lock() |
| 152 | .unwrap_or_else(std::sync::PoisonError::into_inner) |
| 153 | } |
| 154 | } |