comindly-engine 0.5.0-beta.11

comindly's engine: the index behind /api/v1 and /mcp, on this machine or as a team server, with the web app, indexing jobs, members and roles.
Documentation
//! What a server stores and how it signs people in, as traits; `server::Store` and [`Smtp`] are the
//! defaults.

use anyhow::{Context, Result};
use futures::future::BoxFuture;

use crate::server::{
    AccessToken, AuditPage, AuditQuery, DevicePoll, DeviceRequest, Invite, Member, Role,
    SecretInfo, Via, Who,
};

/// Members, invitations, sign-in links and codes, sessions and personal access tokens.
pub trait AccountStore: Send + Sync {
    /// Drop what has expired (links, sessions, tokens); called now and then by a running server.
    fn tidy(&self) {}
    /// The member `email`; deployment admins are always present and admin.
    fn member(&self, email: &str) -> Result<Option<Who>>;
    fn members(&self) -> Result<Vec<Member>>;
    fn set_role(&self, by: &str, email: &str, role: Role) -> Result<()>;
    /// Remove a member with their sessions, tokens and invitations.
    fn remove(&self, by: &str, email: &str) -> Result<()>;
    /// Invite `email`; returns the link token, or with `code` the invitation code.
    fn invite(&self, by: &str, email: &str, role: Role, code: bool) -> Result<String>;
    fn invites(&self) -> Result<Vec<Invite>>;
    fn revoke_invite(&self, by: &str, email: &str) -> Result<()>;
    /// A sign-in link token for a member; `None` for anyone else.
    fn sign_in_link(&self, email: &str) -> Result<Option<String>>;
    /// Exchange a link, an invitation or the admin token for a session.
    fn redeem(&self, token: &str, client: &str) -> Result<(String, Who)>;
    /// Sign in with an email and a code; an invitation code also returns the person's own code.
    fn sign_in_code(
        &self,
        email: &str,
        code: &str,
        client: &str,
    ) -> Result<(String, Who, Option<String>)>;
    /// Sign in someone an identity provider vouched for: a member, someone invited, or with
    /// `join` a newcomer as a viewer. `how` names the provider in the audit log.
    fn sign_in_verified(&self, email: &str, how: &str, join: bool) -> Result<(String, Who)>;
    /// The same check without a session: a member, or someone invited (or with `join` a
    /// newcomer) who joins now. For callers an identity provider vouched for, such as the gateway.
    fn admit(&self, email: &str, join: bool) -> Result<Who>;
    /// A new sign-in code for `email`; their sessions end, except `keep`.
    fn reset_code(&self, by: &str, email: &str, keep: Option<&str>) -> Result<String>;
    fn sign_out(&self, session: &str, everywhere: bool) -> Result<()>;
    /// A token that stops working after `expires_in_days`, or never with `None`.
    fn create_token(&self, who: &Who, name: &str, expires_in_days: Option<u32>) -> Result<String>;
    fn tokens(&self, who: &Who) -> Result<Vec<AccessToken>>;
    fn revoke_token(&self, who: &Who, id: &str) -> Result<()>;
    /// Revoke the personal access token whose value is `token`; `false` when none matched.
    fn revoke_own_token(&self, token: &str) -> Result<bool>;
}

/// Who changed what in accounts, secrets and settings, for admins to read.
pub trait AuditLog: Send + Sync {
    /// Entries newest first, from before `query.before` and matching its filters.
    fn audit_entries(&self, query: &AuditQuery) -> Result<AuditPage>;
    /// Record a change made outside the account store, such as to the prompts.
    fn record(&self, actor: &str, action: &str, detail: &str);
}

/// Command-line sign-in approved in the browser: the command starts it and polls, a signed-in
/// member approves it, and the command receives a personal access token.
pub trait DeviceSignIn: Send + Sync {
    /// A device code for the command and a short code for the member to confirm.
    fn device_start(&self, client: &str, from: &str) -> Result<(String, String)>;
    fn device_info(&self, user_code: &str) -> Option<DeviceRequest>;
    fn device_approve(&self, who: &Who, user_code: &str, approve: bool) -> Result<()>;
    fn device_poll(&self, device_code: &str) -> Result<DevicePoll>;
}

/// Who a request comes from: a session cookie or a bearer token, or nobody.
pub trait Authenticator: Send + Sync {
    fn authenticate(
        &self,
        session: Option<&str>,
        bearer: Option<&str>,
    ) -> Result<Option<(Who, Via)>>;
}

/// Git and LLM tokens, by name.
pub trait SecretStore: Send + Sync {
    /// Store `value` under `name`; an empty value removes it.
    fn set_secret(&self, by: &str, name: &str, value: &str) -> Result<()>;
    fn secret(&self, name: &str) -> Option<String>;
    fn secret_info(&self, name: &str) -> Option<SecretInfo>;
    fn secret_names(&self) -> Vec<String>;
}

/// The server's own settings: the repositories it indexes.
pub trait SettingsStore: Send + Sync {
    fn repos(&self) -> Vec<crate::config::RepoEntry>;
    fn set_repos(&self, by: &str, repos: &[crate::config::RepoEntry]) -> Result<()>;
    /// The `config.toml` an older server kept, until it has been moved over.
    fn legacy_config(&self) -> Option<String>;
    fn drop_legacy_config(&self) -> Result<()>;
    /// The kinds of log lines an admin switched off, comma-separated; `None` until one did.
    fn log_skip(&self) -> Option<String>;
    fn set_log_skip(&self, by: &str, skip: &str) -> Result<()>;
}

/// Sends sign-in links and invitations.
pub trait Mailer: Send + Sync {
    fn send<'a>(
        &'a self,
        to: &'a str,
        subject: &'a str,
        body: &'a str,
    ) -> BoxFuture<'a, Result<()>>;
}

/// Mail through `COMINDLY_SMTP_URL` (e.g. `smtps://user:pass@host`).
pub struct Smtp {
    pub url: Option<String>,
    pub from: String,
}

impl Mailer for Smtp {
    fn send<'a>(
        &'a self,
        to: &'a str,
        subject: &'a str,
        body: &'a str,
    ) -> BoxFuture<'a, Result<()>> {
        Box::pin(async move {
            use lettre::message::header::ContentType;
            use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
            let url = self.url.as_deref().context("no mail server configured")?;
            let msg = Message::builder()
                .from(
                    self.from
                        .parse()
                        .context("COMINDLY_SMTP_FROM is not an address")?,
                )
                .to(to.parse().context("not an email address")?)
                .subject(subject)
                .header(ContentType::TEXT_PLAIN)
                .body(body.to_string())?;
            AsyncSmtpTransport::<Tokio1Executor>::from_url(url)
                .context("COMINDLY_SMTP_URL is not a valid smtp:// or smtps:// URL")?
                .build()
                .send(msg)
                .await
                .map_err(|e| anyhow::anyhow!("sending mail failed: {e}"))?;
            Ok(())
        })
    }
}