liminal-server 0.25.4

Standalone server for the liminal messaging bus
Documentation
//! Channel membership bound to the verified holder and the placed resource
//! (ACCESS-006).
//!
//! A membership is Lys's answer to one question asked through the one shared
//! contract, `lys_pass::membership`: may this verified subject read, or post
//! on, this placed channel resource within this workspace, at least at this
//! grant revision. It is never a liminal roster, a guess from a subject
//! string, a readable parent taken as membership of a child, or a consequence
//! of signing in.
//!
//! - **The mapping is explicit** ([`crate::config::types::MembershipConfig`]):
//!   a channel is membership-protected only when the operator's product
//!   mapping lists it with its workspace and placed resource; a participant
//!   conversation is placed under a channel only when the mapping lists it.
//!   Every other channel keeps its prior contract, byte for byte.
//! - **The subject is the verified holder** ([`MembershipSubject`]): built only
//!   from the admitted Lys session, with the kind the signed pass states, so an
//!   AI is asked about as its own agent identity and never as its responsible
//!   person. A connection that proved no Lys holder has no membership at all.
//! - **The provider is a named seam** ([`MembershipProvider`]); the live one is
//!   [`HttpMembershipProvider`] over Lys's `POST /grants/membership` routes.
//!   Nothing here answers a membership question itself.
//! - **The authority is the ACCESS-005 grant owner** ([`MembershipAuthority`]):
//!   a protected act proceeds only while it states `Ready` on the configured
//!   grant log. `NotReady`, `Unavailable`, `Gap` and `Reset` refuse every
//!   protected act and hold every protected delivery with that cause; no last
//!   applied allow is a fallback (stricter than ACCESS-005 R2's legacy
//!   continuation, and only for protected channels). The owner drives ordered
//!   changes and resumes into the gate through [`MembershipListener`].
//! - **Hot checks are local** ([`MembershipGate::check`]): a decision is asked
//!   once per (holder, channel, action) and retained in a bounded index at the
//!   authority's generation; later acts and delivery releases read it under a
//!   short lock and never ask Lys. A change re-decides only the bindings its
//!   resource reaches (a reverse index), a resume re-decides every retained
//!   binding once, and a binding is released when the last connection holding
//!   it closes.
//! - **Pages are bounded** ([`MembershipGate::resource_page`],
//!   [`MembershipGate::recipient_page`]): both bounds are the operator's,
//!   every row is checked against the question, and a continuation is bound
//!   to its exact question and generation.

use std::collections::{BTreeMap, BTreeSet};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex, MutexGuard, OnceLock};

use lys_pass::membership::{
    CONTRACT_UNSUPPORTED, CONTRACT_VERSION, GrantLog, LOG_MISMATCH, MembershipDecision,
    MembershipRequest, Subject, Verdict,
};
use lys_pass::membership_admission::{AdmissionDecision, AdmissionRequest};
use lys_pass::membership_pages::{
    CURSOR_FOREIGN, CURSOR_RESET, MembershipPage, PageBounds, PageOutcome, PageRow,
    RecipientPageRequest, ResourcePageRequest,
};
use lys_pass::rights::{Mode, Resource};

use crate::ServerError;
use crate::auth_lys::HotAction;
use crate::auth_lys::session::LysSession;
use crate::config::types::MembershipConfig;

pub use crate::agents::binding::check as check_agent_binding;

/// The Lys route answering one membership decision.
pub const DECISION_ROUTE: &str = "grants/membership";
/// The Lys route answering scoped guest admission.
pub const ADMISSION_ROUTE: &str = "grants/membership/admission";
/// The Lys route answering a page of a subject's permitted resources.
pub const RESOURCES_ROUTE: &str = "grants/membership/resources";
/// The Lys route answering a page of a channel's permitted recipients.
pub const RECIPIENTS_ROUTE: &str = "grants/membership/recipients";

mod refusal;
pub use refusal::{MembershipAction, MembershipRefusal};
use refusal::{bump, bump_by, invalid, malformed};

mod mapping;
use mapping::{ChannelMap, channel_map, validated_bounds};
pub use mapping::{MappedChannel, MembershipSubject};

mod provider;
pub use provider::{HttpMembershipProvider, MembershipProvider, ProviderError};

mod authority;
use authority::Counters;
pub use authority::{
    AppliedChange, AuthorityState, Decided, MembershipAuthority, MembershipChange,
    MembershipCounts, MembershipListener, ResumeReport,
};

mod index;
pub use index::{ChannelPage, PageCursor, RecipientPage};
use index::{DecisionIndex, DecisionKey, Indexed, PageParts, PageQuestion, resource_key};

mod gate;
pub use gate::MembershipGate;

mod pages;

mod listener;
use listener::{admitted, continuation, encoded_bytes, judged, page_refusal};

mod connection;
pub use connection::{ConnectionMembership, ReleaseCheck};

#[path = "auth_lys/grant_stream_owner.rs"]
pub mod grant_stream;

/// Checks the complete product mapping without a runtime, credential read or socket.
///
/// # Errors
/// Names an invalid contract, bound, kind, mapping or scoped admission workspace.
pub(crate) fn validate_configuration(config: &MembershipConfig) -> Result<(), ServerError> {
    validated_bounds(config)?;
    let map = channel_map(config)?;
    if config
        .connect_workspace
        .as_ref()
        .is_some_and(|workspace| !map.workspaces.contains(workspace))
    {
        return Err(invalid(
            "auth.lys.membership.connect_workspace is not a mapped workspace",
        ));
    }
    if config.credential_path.as_os_str().is_empty() {
        return Err(invalid(
            "auth.lys.membership.credential_path must name a file",
        ));
    }
    Ok(())
}

/// Builds the membership gate `[auth.lys.membership]` configures over the
/// live HTTP provider at `issuer`, reading the app credential once.
///
/// # Errors
/// [`ServerError::ConfigValidation`] for an unreadable credential, a server
/// started outside its tokio runtime, a refused issuer, or an invalid
/// mapping (see [`MembershipGate::new`]).
pub fn configured_gate(
    config: &MembershipConfig,
    issuer: &str,
) -> Result<Arc<MembershipGate>, ServerError> {
    let handle = tokio::runtime::Handle::try_current()
        .map_err(|_| invalid("auth.lys.membership needs the server's tokio runtime at start"))?;
    configured_gate_on(config, issuer, handle)
}

/// Constructs membership on the server-owned ask handle, including synchronous startup.
///
/// # Errors
/// As for [`configured_gate`], without requiring a thread-local runtime entry.
pub fn configured_gate_on(
    config: &MembershipConfig,
    issuer: &str,
    handle: tokio::runtime::Handle,
) -> Result<Arc<MembershipGate>, ServerError> {
    let response_bytes = usize::try_from(config.page_bytes)
        .map_err(|_| invalid("membership_page_bytes_overflow"))?;
    let credential = grant_stream::read_credential(&config.credential_path, response_bytes)?;
    let gate_state = grant_stream::StreamState::for_log(GrantLog {
        identity: config.log_identity.clone(),
        epoch: config.log_epoch,
    });
    let provider = HttpMembershipProvider::new(
        issuer,
        credential,
        handle,
        response_bytes,
        gate_state.cancellation(),
    )
    .map_err(|error| {
        invalid(format!(
            "auth.lys.membership provider refused: {}",
            error.cause
        ))
    })?;
    let gate = Arc::new(MembershipGate::new(config, Arc::new(provider))?);
    gate.install_authority(gate_state.clone())
        .map_err(|_| invalid("membership_authority_already_installed"))?;
    gate.stream
        .set(gate_state)
        .map_err(|_| invalid("membership_stream_already_installed"))?;
    Ok(gate)
}

/// Fixtures for the membership tests: a seeded world, a settable authority
/// and a verified session. Never compiled into a production build.
#[cfg(any(test, feature = "test-support"))]
pub mod fixture;

#[cfg(test)]
#[path = "auth_membership_tests.rs"]
mod tests;