Skip to main content

comindly_engine/
server.rs

1//! Server mode (on when `COMINDLY_PUBLIC_URL` is set): members, invitations, sign-in links,
2//! sessions, personal access tokens and encrypted secrets, in a LanceDB table next to the index.
3
4use std::collections::{BTreeMap, HashMap};
5use std::sync::{Arc, Mutex};
6
7use anyhow::{bail, Context, Result};
8use chacha20poly1305::aead::{Aead, KeyInit, Payload};
9use chacha20poly1305::{XChaCha20Poly1305, XNonce};
10use chrono::{Duration, Utc};
11use lancedb::arrow::arrow_array::{RecordBatch, RecordBatchIterator, StringArray};
12use lancedb::arrow::arrow_schema::{DataType, Field, Schema};
13use serde::{Deserialize, Serialize};
14
15use crate::backends::{
16    AccountStore, AuditLog, Authenticator, DeviceSignIn, Mailer, SecretStore, SettingsStore,
17};
18
19/// `NAME` from the environment, or the contents of the file `NAME_FILE` names.
20pub fn env_secret(name: &str) -> Option<String> {
21    if let Some(v) = std::env::var(name).ok().filter(|v| !v.trim().is_empty()) {
22        return Some(v.trim().to_string());
23    }
24    let path = std::env::var(format!("{name}_FILE")).ok()?;
25    match std::fs::read_to_string(&path) {
26        Ok(v) => Some(v.trim().to_string()).filter(|v| !v.is_empty()),
27        Err(e) => {
28            crate::ui::warn(&format!("{name}_FILE ({path}) cannot be read: {e}"));
29            None
30        }
31    }
32}
33
34/// The deployment's settings for server mode, read once when the server starts.
35#[derive(Clone)]
36pub struct ServerConfig {
37    /// The address members reach this server at, without a trailing slash.
38    pub public_url: String,
39    /// Always admins, whatever the database says.
40    pub admin_emails: Vec<String>,
41    /// The ways members may sign in, in the order the sign-in page offers them.
42    pub sign_in: Vec<SignIn>,
43    pub admin_token: Option<String>,
44    /// Lets a gateway you run name the member behind an `/mcp` call.
45    pub gateway_secret: Option<String>,
46    /// The header such a gateway names the member in.
47    pub gateway_email_header: String,
48    /// The gateway's MCP address, which the app offers agents instead of a token.
49    pub gateway_url: Option<String>,
50    pub secret_key: String,
51    pub secret_key_previous: Option<String>,
52    pub smtp_url: Option<String>,
53    pub smtp_from: String,
54    /// Take the client address from `X-Forwarded-For`, behind a proxy you run.
55    pub trust_proxy: bool,
56    /// The deployment's LLM key, which wins over one stored here.
57    pub llm_key: Option<String>,
58    pub oidc: Option<OidcConfig>,
59}
60
61/// Sign-in through an OpenID Connect provider, such as the company's identity provider.
62#[derive(Clone)]
63pub struct OidcConfig {
64    pub issuer: String,
65    pub client_id: String,
66    pub client_secret: String,
67    /// The provider's name on the sign-in button.
68    pub label: String,
69    /// Addresses at these domains join as viewers on first sign-in; anyone else needs an invitation.
70    pub allowed_domains: Vec<String>,
71}
72
73impl OidcConfig {
74    fn from_env() -> Result<Option<OidcConfig>> {
75        let Some(issuer) = env_secret("COMINDLY_OIDC_ISSUER") else {
76            return Ok(None);
77        };
78        Ok(Some(OidcConfig {
79            issuer: issuer.trim_end_matches('/').to_string(),
80            client_id: env_secret("COMINDLY_OIDC_CLIENT_ID")
81                .context("COMINDLY_OIDC_ISSUER needs COMINDLY_OIDC_CLIENT_ID")?,
82            client_secret: env_secret("COMINDLY_OIDC_CLIENT_SECRET")
83                .context("COMINDLY_OIDC_ISSUER needs COMINDLY_OIDC_CLIENT_SECRET")?,
84            label: env_secret("COMINDLY_OIDC_LABEL")
85                .unwrap_or_else(|| "your company account".into()),
86            allowed_domains: env_secret("COMINDLY_OIDC_ALLOWED_DOMAINS")
87                .unwrap_or_default()
88                .split([',', ' ', '\n'])
89                .map(|d| normalize(d).trim_start_matches('@').to_string())
90                .filter(|d| !d.is_empty())
91                .collect(),
92        }))
93    }
94}
95
96impl ServerConfig {
97    /// `None` without `COMINDLY_PUBLIC_URL`; an error when server mode is set up wrong.
98    pub fn from_env() -> Result<Option<ServerConfig>> {
99        let Some(url) = env_secret("COMINDLY_PUBLIC_URL") else {
100            return Ok(None);
101        };
102        let admin_emails: Vec<String> = env_secret("COMINDLY_ADMIN_EMAILS")
103            .unwrap_or_default()
104            .split([',', ' ', '\n'])
105            .map(normalize)
106            .filter(|e| e.contains('@'))
107            .collect();
108        anyhow::ensure!(
109            !admin_emails.is_empty(),
110            "server mode needs COMINDLY_ADMIN_EMAILS: who administers this server"
111        );
112        let admin_token = env_secret("COMINDLY_ADMIN_TOKEN");
113        if let Some(t) = &admin_token {
114            anyhow::ensure!(
115                t.len() >= 32,
116                "COMINDLY_ADMIN_TOKEN is too short: use at least 32 random characters (e.g. `openssl rand -base64 32`)"
117            );
118        }
119        let gateway_secret = env_secret("COMINDLY_GATEWAY_SECRET");
120        if let Some(t) = &gateway_secret {
121            anyhow::ensure!(
122                t.len() >= 32,
123                "COMINDLY_GATEWAY_SECRET is too short: use at least 32 random characters (e.g. `openssl rand -base64 32`)"
124            );
125        }
126        let smtp_url = env_secret("COMINDLY_SMTP_URL");
127        let oidc = OidcConfig::from_env()?;
128        let sign_in = sign_in_methods(
129            env_secret("COMINDLY_SIGN_IN").as_deref(),
130            smtp_url.is_some(),
131            oidc.is_some(),
132        )?;
133        Ok(Some(ServerConfig {
134            public_url: url.trim_end_matches('/').to_string(),
135            admin_emails,
136            sign_in,
137            admin_token,
138            gateway_secret,
139            gateway_email_header: env_secret("COMINDLY_GATEWAY_EMAIL_HEADER")
140                .unwrap_or_else(|| "x-forwarded-email".into())
141                .to_lowercase(),
142            gateway_url: env_secret("COMINDLY_GATEWAY_URL"),
143            secret_key: env_secret("COMINDLY_SECRET_KEY")
144                .context("server mode needs COMINDLY_SECRET_KEY")?,
145            secret_key_previous: env_secret("COMINDLY_SECRET_KEY_PREVIOUS"),
146            smtp_url,
147            smtp_from: env_secret("COMINDLY_SMTP_FROM")
148                .unwrap_or_else(|| "comindly <comindly@localhost>".into()),
149            trust_proxy: env_secret("COMINDLY_TRUST_PROXY")
150                .is_some_and(|v| v == "true" || v == "1"),
151            llm_key: env_secret("OPENAI_API_KEY"),
152            oidc,
153        }))
154    }
155
156    pub fn https(&self) -> bool {
157        self.public_url.starts_with("https://")
158    }
159
160    pub fn allows(&self, method: SignIn) -> bool {
161        self.sign_in.contains(&method)
162    }
163
164    /// One method for clients that know only one: email or code when on, else the provider.
165    pub fn main_sign_in(&self) -> SignIn {
166        [SignIn::Email, SignIn::Code]
167            .into_iter()
168            .find(|m| self.allows(*m))
169            .unwrap_or(SignIn::Oidc)
170    }
171}
172
173/// `COMINDLY_SIGN_IN` (e.g. `oidc,email,code`), or by default the provider and email where set up,
174/// else codes.
175fn sign_in_methods(set: Option<&str>, smtp: bool, oidc: bool) -> Result<Vec<SignIn>> {
176    let chosen: Vec<SignIn> = match set {
177        Some(v) => v
178            .split([',', ' '])
179            .filter(|m| !m.is_empty())
180            .map(|m| match m {
181                "email" => Ok(SignIn::Email),
182                "code" => Ok(SignIn::Code),
183                "oidc" => Ok(SignIn::Oidc),
184                other => bail!("COMINDLY_SIGN_IN lists `oidc`, `email` and `code`, not `{other}`"),
185            })
186            .collect::<Result<_>>()?,
187        None if oidc || smtp => [(oidc, SignIn::Oidc), (smtp, SignIn::Email)]
188            .into_iter()
189            .filter_map(|(on, m)| on.then_some(m))
190            .collect(),
191        None => vec![SignIn::Code],
192    };
193    anyhow::ensure!(
194        !chosen.is_empty(),
195        "COMINDLY_SIGN_IN names no way to sign in"
196    );
197    anyhow::ensure!(
198        smtp || !chosen.contains(&SignIn::Email),
199        "email sign-in needs COMINDLY_SMTP_URL"
200    );
201    anyhow::ensure!(
202        oidc || !chosen.contains(&SignIn::Oidc),
203        "oidc sign-in needs COMINDLY_OIDC_ISSUER"
204    );
205    Ok([SignIn::Oidc, SignIn::Email, SignIn::Code]
206        .into_iter()
207        .filter(|m| chosen.contains(m))
208        .collect())
209}
210
211/// A running server: its settings and its backends.
212pub struct Server {
213    pub config: ServerConfig,
214    pub accounts: Arc<dyn AccountStore>,
215    pub audit: Arc<dyn AuditLog>,
216    pub auth: Arc<dyn Authenticator>,
217    pub devices: Arc<dyn DeviceSignIn>,
218    pub secrets: Arc<dyn SecretStore>,
219    pub settings: Arc<dyn SettingsStore>,
220    pub mailer: Arc<dyn Mailer>,
221    pub jobs: Arc<dyn crate::jobs::JobStore>,
222    /// The OpenID Connect provider, when members sign in through one.
223    pub oidc: Option<Arc<crate::oidc::Oidc>>,
224}
225
226impl Server {
227    /// The default backends: a signed table under `index_root`, and SMTP.
228    pub fn open(index_root: &str, config: ServerConfig) -> Result<Server> {
229        let store = Arc::new(Store::open(index_root, &config)?);
230        let mailer = Arc::new(crate::backends::Smtp {
231            url: config.smtp_url.clone(),
232            from: config.smtp_from.clone(),
233        });
234        let oidc = match &config.oidc {
235            Some(c) if config.allows(SignIn::Oidc) => Some(Arc::new(crate::oidc::Oidc::new(
236                c.clone(),
237                &config.public_url,
238            )?)),
239            _ => None,
240        };
241        Ok(Server {
242            config,
243            accounts: store.clone(),
244            audit: store.clone(),
245            auth: store.clone(),
246            devices: store.clone(),
247            secrets: store.clone(),
248            settings: store.clone(),
249            jobs: store,
250            mailer,
251            oidc,
252        })
253    }
254
255    /// A git host's token: the one stored here, else the deployment's.
256    pub fn git_token(&self, host: &str) -> Option<String> {
257        self.secrets
258            .secret(&git_secret(host))
259            .or_else(|| crate::config::env_token(host))
260    }
261}
262
263pub use comindly_api::server::{
264    AccessToken, AuditEntry, AuditPage, AuditQuery, DevicePoll, DeviceRequest, Invite, Member,
265    Role, SecretInfo, SignIn, Via,
266};
267
268pub fn normalize(email: &str) -> String {
269    email.trim().to_lowercase()
270}
271
272/// Who a request comes from.
273#[derive(Debug, Clone, Serialize)]
274pub struct Who {
275    pub email: String,
276    pub role: Role,
277}
278
279/// The app a gateway request came from, as the gateway names its OAuth client ("Claude Code").
280#[derive(Debug, Clone)]
281pub struct GatewayClient(pub String);
282
283const SESSION_DAYS: i64 = 30;
284const LINK_MINUTES: i64 = 15;
285const INVITE_DAYS: i64 = 7;
286const MAX_FAILED: i64 = 5;
287const MAX_REDEEM_FAILED: i64 = 20;
288const LOCK_MINUTES: i64 = 15;
289const MAX_LINKS_PER_HOUR: i64 = 5;
290const DEVICE_MINUTES: i64 = 10;
291/// Command-line sign-ins one address may start in `LOCK_MINUTES`.
292const MAX_DEVICE_STARTS: i64 = 10;
293pub use comindly_api::server::TOKEN_DAYS;
294/// Expired tokens stay listed (as expired) this long before they are removed.
295const EXPIRED_TOKEN_DAYS: i64 = 30;
296const AUDIT_PAGE: usize = 50;
297const AUDIT_PAGE_MAX: usize = 500;
298const CODE_ALPHABET: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
299
300fn now() -> String {
301    Utc::now().to_rfc3339()
302}
303
304/// A random URL-safe token with `prefix`.
305pub fn new_token(prefix: &str) -> String {
306    let mut b = [0u8; 32];
307    getrandom::fill(&mut b).expect("system randomness");
308    format!("{prefix}{}", blake3::Hash::from(b).to_hex())
309}
310
311/// A sign-in or invitation code people type: 20 characters (100 bits) in groups of four.
312pub fn new_code() -> String {
313    let mut b = [0u8; 20];
314    getrandom::fill(&mut b).expect("system randomness");
315    let chars: Vec<char> = b
316        .iter()
317        .map(|x| CODE_ALPHABET[(*x as usize) % CODE_ALPHABET.len()] as char)
318        .collect();
319    chars
320        .chunks(4)
321        .map(|c| c.iter().collect::<String>())
322        .collect::<Vec<_>>()
323        .join("-")
324}
325
326/// A code as typed, without case, spaces or dashes mattering.
327fn code_hash(code: &str) -> String {
328    let plain: String = code
329        .chars()
330        .filter(char::is_ascii_alphanumeric)
331        .map(|c| c.to_ascii_uppercase())
332        .collect();
333    hash(&format!("code:{plain}"))
334}
335
336fn hash(token: &str) -> String {
337    blake3::hash(token.as_bytes()).to_hex().to_string()
338}
339
340fn cipher(key: &str) -> XChaCha20Poly1305 {
341    let k = blake3::derive_key("comindly secrets v1", key.as_bytes());
342    XChaCha20Poly1305::new((&k).into())
343}
344
345/// Encrypt `value`, bound to the secret's `name` so a value cannot be moved to another name.
346fn seal(key: &str, name: &str, value: &str) -> Result<(Vec<u8>, Vec<u8>)> {
347    let mut nonce = [0u8; 24];
348    getrandom::fill(&mut nonce).context("randomness")?;
349    let payload = Payload {
350        msg: value.as_bytes(),
351        aad: name.as_bytes(),
352    };
353    let sealed = cipher(key)
354        .encrypt(&XNonce::from(nonce), payload)
355        .map_err(|_| anyhow::anyhow!("encryption failed"))?;
356    Ok((sealed, nonce.to_vec()))
357}
358
359fn open(key: &str, name: &str, sealed: &[u8], nonce: &[u8]) -> Option<String> {
360    let payload = Payload {
361        msg: sealed,
362        aad: name.as_bytes(),
363    };
364    let plain = cipher(key)
365        .decrypt(&XNonce::try_from(nonce).ok()?, payload)
366        .ok()?;
367    String::from_utf8(plain).ok()
368}
369
370/// The last four characters, to recognise a token without showing it.
371fn hint(value: &str) -> String {
372    let tail: String = value
373        .chars()
374        .rev()
375        .take(4)
376        .collect::<Vec<_>>()
377        .into_iter()
378        .rev()
379        .collect();
380    format!("…{tail}")
381}
382
383#[derive(Clone, Serialize, Deserialize)]
384struct User {
385    role: Role,
386    created: String,
387    last_seen: Option<String>,
388}
389
390#[derive(Clone, Serialize, Deserialize)]
391struct Invitation {
392    email: String,
393    role: Role,
394    created_by: String,
395    created: String,
396    expires: String,
397}
398
399#[derive(Clone, Serialize, Deserialize)]
400struct Link {
401    email: String,
402    created: String,
403    expires: String,
404}
405
406/// A sign-in asked for by a command line, until a member approves it and the command collects it.
407#[derive(Clone, Serialize, Deserialize)]
408struct Device {
409    /// The hash of the code the member confirms in the browser.
410    user_code: String,
411    client: String,
412    created: String,
413    expires: String,
414    approved_by: Option<String>,
415    denied: bool,
416    /// The token issued on approval, sealed until it is collected: `(sealed, nonce)` in hex.
417    token: Option<(String, String)>,
418}
419
420#[derive(Clone, Serialize, Deserialize)]
421struct Session {
422    email: String,
423    created: String,
424    expires: String,
425}
426
427#[derive(Clone, Serialize, Deserialize)]
428struct Token {
429    hash: String,
430    email: String,
431    name: String,
432    created: String,
433    last_used: Option<String>,
434    #[serde(default)]
435    expires: Option<String>,
436}
437
438impl Token {
439    fn expired(&self, now: &str) -> bool {
440        self.expires.as_deref().is_some_and(|e| e <= now)
441    }
442}
443
444#[derive(Clone, Serialize, Deserialize)]
445struct Secret {
446    sealed: String,
447    nonce: String,
448    hint: String,
449    updated: String,
450    updated_by: String,
451}
452
453#[derive(Clone, Serialize, Deserialize)]
454struct Code {
455    hash: String,
456    created: String,
457}
458
459/// Everything the server knows, as loaded from its table. Failed attempts stay in memory.
460#[derive(Default)]
461struct State {
462    users: BTreeMap<String, User>,
463    invites: HashMap<String, Invitation>,
464    links: HashMap<String, Link>,
465    sessions: HashMap<String, Session>,
466    tokens: HashMap<String, Token>,
467    secrets: BTreeMap<String, Secret>,
468    codes: HashMap<String, Code>,
469    /// When admins named by the deployment were last seen; they have no `user` row.
470    seen: HashMap<String, String>,
471    /// The server's `config.toml`, for containers that start without it.
472    config: HashMap<String, String>,
473    devices: HashMap<String, Device>,
474    jobs: HashMap<String, crate::jobs::JobRecord>,
475    job_events: HashMap<String, Vec<crate::events::Event>>,
476    failures: Vec<(String, String)>,
477}
478
479/// One row to write (`Some` json) or delete (`None`), by kind and key.
480type Change = (&'static str, String, Option<String>);
481
482fn put(kind: &'static str, key: &str, value: &impl Serialize) -> Change {
483    (kind, key.to_string(), serde_json::to_string(value).ok())
484}
485
486fn del(kind: &'static str, key: &str) -> Change {
487    (kind, key.to_string(), None)
488}
489
490impl State {
491    fn load(&mut self, kind: &str, key: String, json: &str) {
492        fn add<T: serde::de::DeserializeOwned>(
493            m: &mut impl Extend<(String, T)>,
494            key: String,
495            json: &str,
496        ) {
497            if let Ok(v) = serde_json::from_str(json) {
498                m.extend([(key, v)]);
499            }
500        }
501        match kind {
502            "user" => add(&mut self.users, key, json),
503            "invite" => add(&mut self.invites, key, json),
504            "link" => add(&mut self.links, key, json),
505            "session" => add(&mut self.sessions, key, json),
506            "token" => add(&mut self.tokens, key, json),
507            "secret" => add(&mut self.secrets, key, json),
508            "code" => add(&mut self.codes, key, json),
509            "seen" => add(&mut self.seen, key, json),
510            "config" => add(&mut self.config, key, json),
511            "device" => add(&mut self.devices, key, json),
512            "job" => add(&mut self.jobs, key, json),
513            "job-events" => add(&mut self.job_events, key, json),
514            _ => {}
515        }
516    }
517
518    /// Apply changes already written to the table.
519    fn apply(&mut self, changes: &[Change]) {
520        for (kind, key, json) in changes {
521            match json {
522                Some(j) => self.load(kind, key.clone(), j),
523                None => match *kind {
524                    "user" => drop(self.users.remove(key)),
525                    "invite" => drop(self.invites.remove(key)),
526                    "link" => drop(self.links.remove(key)),
527                    "session" => drop(self.sessions.remove(key)),
528                    "token" => drop(self.tokens.remove(key)),
529                    "secret" => drop(self.secrets.remove(key)),
530                    "code" => drop(self.codes.remove(key)),
531                    "seen" => drop(self.seen.remove(key)),
532                    "config" => drop(self.config.remove(key)),
533                    "device" => drop(self.devices.remove(key)),
534                    "job" => drop(self.jobs.remove(key)),
535                    "job-events" => drop(self.job_events.remove(key)),
536                    _ => {}
537                },
538            }
539        }
540    }
541}
542
543const TABLE: &str = "server";
544const CONFIG: &str = "config.toml";
545const REPOS: &str = "repos";
546const LOG_SKIP: &str = "log-skip";
547const KINDS: [&str; 12] = [
548    "user",
549    "invite",
550    "link",
551    "session",
552    "token",
553    "secret",
554    "code",
555    "seen",
556    "config",
557    "device",
558    "job",
559    "job-events",
560];
561
562/// LanceDB work from the store's synchronous methods.
563fn run<T: Send + 'static>(
564    f: impl std::future::Future<Output = Result<T>> + Send + 'static,
565) -> Result<T> {
566    crate::rt::block_on(f)
567}
568
569fn schema() -> Arc<Schema> {
570    Arc::new(Schema::new(vec![
571        Field::new("kind", DataType::Utf8, false),
572        Field::new("key", DataType::Utf8, false),
573        Field::new("json", DataType::Utf8, false),
574        Field::new("mac", DataType::Utf8, false),
575    ]))
576}
577
578/// The key that proves a row was written by this server, from `COMINDLY_SECRET_KEY`.
579fn mac_key(secret: &str) -> [u8; 32] {
580    blake3::derive_key("comindly rows v1", secret.as_bytes())
581}
582
583fn mac(key: &[u8; 32], kind: &str, k: &str, json: &str) -> String {
584    blake3::keyed_hash(key, format!("{kind}\0{k}\0{json}").as_bytes())
585        .to_hex()
586        .to_string()
587}
588
589fn quote(s: &str) -> String {
590    format!("'{}'", s.replace('\'', "''"))
591}
592
593async fn table(uri: &str) -> Result<lancedb::Table> {
594    let db = crate::storage::connect(uri).execute().await?;
595    match db.open_table(TABLE).execute().await {
596        Ok(t) => Ok(t),
597        Err(_) => match db.create_empty_table(TABLE, schema()).execute().await {
598            Ok(t) => Ok(t),
599            // Another instance created it first.
600            Err(_) => Ok(db.open_table(TABLE).execute().await?),
601        },
602    }
603}
604
605/// Every row except the audit log, and the table version they came from.
606async fn read_all(uri: String) -> Result<(u64, Vec<[String; 4]>)> {
607    read_rows(uri, "kind != 'audit'").await
608}
609
610/// Rows matching `filter` as `[kind, key, json, mac]`, and the table version they came from.
611async fn read_rows(uri: String, filter: &str) -> Result<(u64, Vec<[String; 4]>)> {
612    use futures::TryStreamExt;
613    use lancedb::query::{ExecutableQuery, QueryBase};
614    let tbl = table(&uri).await?;
615    let version = tbl.version().await?;
616    let mut st = tbl.query().only_if(filter).execute().await?;
617    let mut rows = Vec::new();
618    while let Some(b) = st.try_next().await? {
619        let col = |n: &str| {
620            b.column_by_name(n)
621                .and_then(|c| c.as_any().downcast_ref::<StringArray>().cloned())
622        };
623        let (Some(k), Some(key), Some(j), Some(m)) =
624            (col("kind"), col("key"), col("json"), col("mac"))
625        else {
626            continue;
627        };
628        for i in 0..b.num_rows() {
629            rows.push([k.value(i), key.value(i), j.value(i), m.value(i)].map(str::to_string));
630        }
631    }
632    Ok((version, rows))
633}
634
635async fn write(uri: String, key: [u8; 32], changes: Vec<Change>) -> Result<u64> {
636    let tbl = table(&uri).await?;
637    let (puts, dels): (Vec<_>, Vec<_>) = changes.into_iter().partition(|(_, _, j)| j.is_some());
638    if !dels.is_empty() {
639        let pred = dels
640            .iter()
641            .map(|(k, key, _)| format!("(kind = {} AND key = {})", quote(k), quote(key)))
642            .collect::<Vec<_>>()
643            .join(" OR ");
644        tbl.delete(&pred).await?;
645    }
646    if !puts.is_empty() {
647        let batch = RecordBatch::try_new(
648            schema(),
649            vec![
650                Arc::new(StringArray::from_iter_values(
651                    puts.iter().map(|(k, _, _)| *k),
652                )),
653                Arc::new(StringArray::from_iter_values(
654                    puts.iter().map(|(_, key, _)| key.as_str()),
655                )),
656                Arc::new(StringArray::from_iter_values(
657                    puts.iter()
658                        .map(|(_, _, j)| j.as_deref().unwrap_or_default()),
659                )),
660                Arc::new(StringArray::from_iter_values(puts.iter().map(
661                    |(k, key_, j)| mac(&key, k, key_, j.as_deref().unwrap_or_default()),
662                ))),
663            ],
664        )?;
665        let reader = RecordBatchIterator::new(vec![Ok(batch)], schema());
666        let mut m = tbl.merge_insert(&["kind", "key"]);
667        m.when_matched_update_all(None)
668            .when_not_matched_insert_all();
669        m.execute(Box::new(reader)).await?;
670    }
671    Ok(tbl.version().await?)
672}
673
674/// Fold small writes together and drop versions older than a day.
675async fn optimize(uri: String) -> Result<()> {
676    use lancedb::table::OptimizeAction;
677    let tbl = table(&uri).await?;
678    tbl.optimize(OptimizeAction::Compact {
679        options: Default::default(),
680        remap_options: None,
681    })
682    .await?;
683    tbl.optimize(OptimizeAction::Prune {
684        older_than: Duration::try_days(1),
685        delete_unverified: Some(false),
686        error_if_tagged_old_versions: Some(false),
687    })
688    .await?;
689    Ok(())
690}
691
692const REFRESH_SECONDS: i64 = 15;
693
694pub struct Store {
695    uri: String,
696    /// Row keys from `COMINDLY_SECRET_KEY`, then `COMINDLY_SECRET_KEY_PREVIOUS`.
697    keys: Vec<[u8; 32]>,
698    /// Seals stored secrets; the previous one still opens them until they are sealed again.
699    key: String,
700    previous: Option<String>,
701    admins: Vec<String>,
702    admin_token: Option<String>,
703    state: Mutex<State>,
704    /// The table version `state` reflects, and when it was last compared with the table.
705    seen: Mutex<(u64, chrono::DateTime<Utc>)>,
706    /// Serialises writes, so a single-use invitation or link is used once.
707    writing: Mutex<()>,
708}
709
710impl Store {
711    /// The server's table under the index location (`<index>/_server`), created when missing.
712    pub fn open(index_root: &str, config: &ServerConfig) -> Result<Store> {
713        let mut store = Store::open_keyed(
714            index_root,
715            &config.secret_key,
716            config.secret_key_previous.as_deref(),
717        )?;
718        store.admins = config.admin_emails.clone();
719        store.admin_token = config.admin_token.clone();
720        Ok(store)
721    }
722
723    fn open_keyed(index_root: &str, key: &str, previous: Option<&str>) -> Result<Store> {
724        let store = Store::attach_keyed(index_root, key, previous)?;
725        store.rotate_key()?;
726        store.prune();
727        let unreadable = store.unreadable_secrets();
728        if !unreadable.is_empty() {
729            crate::ui::warn(&format!(
730                "{} stored secret(s) cannot be decrypted with COMINDLY_SECRET_KEY ({}); \
731                 set COMINDLY_SECRET_KEY_PREVIOUS to the old key, or save them again",
732                unreadable.len(),
733                unreadable.join(", ")
734            ));
735        }
736        Ok(store)
737    }
738
739    /// The table as it is, without the server's upkeep.
740    fn attach_keyed(index_root: &str, key: &str, previous: Option<&str>) -> Result<Store> {
741        let uri = format!("{}/_server", index_root.trim_end_matches('/'));
742        let store = Store {
743            uri,
744            keys: [Some(key), previous]
745                .into_iter()
746                .flatten()
747                .map(mac_key)
748                .collect(),
749            key: key.to_string(),
750            previous: previous.map(str::to_string),
751            admins: Vec::new(),
752            admin_token: None,
753            state: Mutex::new(State::default()),
754            seen: Mutex::new((0, Utc::now())),
755            writing: Mutex::new(()),
756        };
757        store.reload()?;
758        Ok(store)
759    }
760
761    /// Re-read the table; rows without a valid mac (not written by this server) are ignored,
762    /// and rows under the previous key are signed again with the current one.
763    fn reload(&self) -> Result<()> {
764        let (version, rows) = run(read_all(self.uri.clone()))?;
765        let mut fresh = State::default();
766        let (mut forged, mut resign) = (0, Vec::new());
767        for [kind, key, json, m] in rows {
768            let Some(i) = self
769                .keys
770                .iter()
771                .position(|k| constant_eq(&mac(k, &kind, &key, &json), &m))
772            else {
773                forged += 1;
774                continue;
775            };
776            if i > 0 {
777                if let Some(k) = KINDS.iter().find(|k| **k == kind) {
778                    resign.push((*k, key.clone(), Some(json.clone())));
779                }
780            }
781            fresh.load(&kind, key, &json);
782        }
783        if forged > 0 {
784            crate::ui::warn(&format!(
785                "ignored {forged} server row(s) not signed with COMINDLY_SECRET_KEY"
786            ));
787        }
788        let mut st = self.lock();
789        fresh.failures = std::mem::take(&mut st.failures);
790        *st = fresh;
791        *self.seen.lock().unwrap_or_else(|e| e.into_inner()) = (version, Utc::now());
792        drop(st);
793        self.commit(resign)
794    }
795
796    /// The current state, re-read when another instance changed the table.
797    fn state(&self) -> std::sync::MutexGuard<'_, State> {
798        let stale = {
799            let seen = self.seen.lock().unwrap_or_else(|e| e.into_inner());
800            Utc::now() - seen.1 > Duration::seconds(REFRESH_SECONDS)
801        };
802        if stale {
803            let uri = self.uri.clone();
804            let version = run(async move { Ok(table(&uri).await?.version().await?) });
805            let mut seen = self.seen.lock().unwrap_or_else(|e| e.into_inner());
806            seen.1 = Utc::now();
807            let changed = version.is_ok_and(|v| v != seen.0);
808            drop(seen);
809            if changed {
810                if let Err(e) = self.reload() {
811                    crate::ui::warn(&format!("re-reading the server table: {e:#}"));
812                }
813            }
814        }
815        self.lock()
816    }
817
818    fn lock(&self) -> std::sync::MutexGuard<'_, State> {
819        self.state.lock().unwrap_or_else(|e| e.into_inner())
820    }
821
822    /// Write `changes` to the table, then to memory.
823    fn commit(&self, changes: Vec<Change>) -> Result<()> {
824        if changes.is_empty() {
825            return Ok(());
826        }
827        let version = run(write(self.uri.clone(), self.keys[0], changes.clone()))
828            .context("save to the server table")?;
829        self.lock().apply(&changes);
830        let mut seen = self.seen.lock().unwrap_or_else(|e| e.into_inner());
831        if version == seen.0 + 1 || version == seen.0 + 2 {
832            seen.0 = version;
833        }
834        Ok(())
835    }
836
837    /// Drop expired links, invitations, sessions and tokens and old failed attempts; tidy the table.
838    fn prune(&self) {
839        let t = now();
840        let day = (Utc::now() - Duration::days(1)).to_rfc3339();
841        let mut changes = Vec::new();
842        {
843            let mut st = self.state();
844            changes.extend(
845                st.links
846                    .iter()
847                    .filter(|(_, l)| l.expires < t)
848                    .map(|(k, _)| del("link", k)),
849            );
850            changes.extend(
851                st.invites
852                    .iter()
853                    .filter(|(_, i)| i.expires < t)
854                    .map(|(k, _)| del("invite", k)),
855            );
856            changes.extend(
857                st.sessions
858                    .iter()
859                    .filter(|(_, s)| s.expires < t)
860                    .map(|(k, _)| del("session", k)),
861            );
862            changes.extend(
863                st.devices
864                    .iter()
865                    .filter(|(_, d)| d.expires < t)
866                    .map(|(k, _)| del("device", k)),
867            );
868            changes.extend(long_expired(&st.tokens, |_| true));
869            st.failures.retain(|(_, at)| *at >= day);
870        }
871        if let Err(e) = self.commit(changes) {
872            crate::ui::warn(&format!("pruning the server table: {e:#}"));
873        }
874        if let Err(e) = run(optimize(self.uri.clone())) {
875            crate::ui::warn(&format!("optimizing the server table: {e:#}"));
876        }
877    }
878
879    /// Names of stored secrets the current key cannot open.
880    fn unreadable_secrets(&self) -> Vec<String> {
881        self.state()
882            .secrets
883            .iter()
884            .filter(|(n, s)| unseal(&self.key, n, s).is_none())
885            .map(|(n, _)| n.clone())
886            .collect()
887    }
888
889    /// Failed attempts under `key` in the last few minutes.
890    fn failures(&self, key: &str) -> i64 {
891        let since = (Utc::now() - Duration::minutes(LOCK_MINUTES)).to_rfc3339();
892        self.lock()
893            .failures
894            .iter()
895            .filter(|(k, at)| k == key && *at > since)
896            .count() as i64
897    }
898
899    fn fail(&self, key: &str) {
900        self.lock().failures.push((key.to_string(), now()));
901    }
902
903    fn forgive(&self, key: &str) {
904        self.lock().failures.retain(|(k, _)| k != key);
905    }
906
907    pub fn audit(&self, actor: &str, action: &str, detail: &str) {
908        let row =
909            serde_json::json!({"at": now(), "actor": actor, "action": action, "detail": detail});
910        let key = uuid::Uuid::new_v4().simple().to_string();
911        if let Err(e) = run(write(
912            self.uri.clone(),
913            self.keys[0],
914            vec![("audit", key, Some(row.to_string()))],
915        )) {
916            crate::ui::warn(&format!("audit log: {e:#}"));
917        }
918    }
919
920    /// Audit rows this server signed, with the index of the key that signed each.
921    fn audit_rows(&self) -> Result<Vec<(String, AuditEntry, String, usize)>> {
922        let (_, rows) = run(read_rows(self.uri.clone(), "kind = 'audit'"))?;
923        Ok(rows
924            .into_iter()
925            .filter_map(|[kind, key, json, m]| {
926                let i = self
927                    .keys
928                    .iter()
929                    .position(|k| constant_eq(&mac(k, &kind, &key, &json), &m))?;
930                let v: serde_json::Value = serde_json::from_str(&json).ok()?;
931                let s = |n: &str| v[n].as_str().unwrap_or_default().to_string();
932                let entry = AuditEntry {
933                    at: s("at"),
934                    actor: s("actor"),
935                    action: s("action"),
936                    detail: s("detail"),
937                };
938                Some((key, entry, json, i))
939            })
940            .collect())
941    }
942
943    fn session_for(&self, email: &str) -> Result<(String, Who)> {
944        let who = self.member(email)?.context("not a member")?;
945        let token = new_token("ses_");
946        let session = Session {
947            email: who.email.clone(),
948            created: now(),
949            expires: (Utc::now() + Duration::days(SESSION_DAYS)).to_rfc3339(),
950        };
951        self.commit(vec![put("session", &hash(&token), &session)])?;
952        Ok((token, who))
953    }
954
955    /// Re-seal secrets stored under `COMINDLY_SECRET_KEY_PREVIOUS` with the current key.
956    fn rotate_key(&self) -> Result<()> {
957        let (key, Some(old)) = (self.key.clone(), self.previous.clone()) else {
958            return Ok(());
959        };
960        let rows: Vec<(String, Secret)> = self
961            .state()
962            .secrets
963            .iter()
964            .map(|(n, s)| (n.clone(), s.clone()))
965            .collect();
966        let mut changes = Vec::new();
967        for (name, row) in rows {
968            if unseal(&key, &name, &row).is_some() {
969                continue;
970            }
971            let Some(plain) = unseal(&old, &name, &row) else {
972                continue;
973            };
974            let mut again = sealed(&key, &name, &plain, &row.updated_by)?;
975            again.updated = row.updated;
976            changes.push(put("secret", &name, &again));
977        }
978        self.commit(changes)?;
979        let audit: Vec<Change> = self
980            .audit_rows()?
981            .into_iter()
982            .filter(|(.., i)| *i > 0)
983            .map(|(key, _, json, _)| ("audit", key, Some(json)))
984            .collect();
985        if !audit.is_empty() {
986            run(write(self.uri.clone(), self.keys[0], audit))?;
987        }
988        Ok(())
989    }
990
991    /// Only admins invite, change or remove people.
992    fn may_manage(&self, by: &str) -> Result<()> {
993        use crate::backends::AccountStore;
994        anyhow::ensure!(
995            self.member(by)?.is_some_and(|w| w.role == Role::Admin),
996            "only admins can do this"
997        );
998        Ok(())
999    }
1000
1001    fn write_lock(&self) -> std::sync::MutexGuard<'_, ()> {
1002        self.writing.lock().unwrap_or_else(|e| e.into_inner())
1003    }
1004}
1005
1006/// Tokens of whoever `whose` accepts that expired more than `EXPIRED_TOKEN_DAYS` ago.
1007fn long_expired(tokens: &HashMap<String, Token>, whose: impl Fn(&str) -> bool) -> Vec<Change> {
1008    let gone = (Utc::now() - Duration::days(EXPIRED_TOKEN_DAYS)).to_rfc3339();
1009    tokens
1010        .iter()
1011        .filter(|(_, t)| whose(&t.email) && t.expired(&gone))
1012        .map(|(k, _)| del("token", k))
1013        .collect()
1014}
1015
1016fn sealed(key: &str, name: &str, value: &str, by: &str) -> Result<Secret> {
1017    let (s, n) = seal(key, name, value)?;
1018    Ok(Secret {
1019        sealed: hex(&s),
1020        nonce: hex(&n),
1021        hint: hint(value),
1022        updated: now(),
1023        updated_by: by.to_string(),
1024    })
1025}
1026
1027fn unseal(key: &str, name: &str, row: &Secret) -> Option<String> {
1028    open(key, name, &unhex(&row.sealed)?, &unhex(&row.nonce)?)
1029}
1030
1031fn hex(b: &[u8]) -> String {
1032    b.iter().map(|x| format!("{x:02x}")).collect()
1033}
1034
1035fn unhex(s: &str) -> Option<Vec<u8>> {
1036    (0..s.len())
1037        .step_by(2)
1038        .map(|i| u8::from_str_radix(s.get(i..i + 2)?, 16).ok())
1039        .collect()
1040}
1041
1042/// Equal without revealing where or how long the values differ.
1043pub(crate) fn constant_eq(a: &str, b: &str) -> bool {
1044    let (a, b) = (blake3::hash(a.as_bytes()), blake3::hash(b.as_bytes()));
1045    a.as_bytes()
1046        .iter()
1047        .zip(b.as_bytes())
1048        .fold(0u8, |acc, (x, y)| acc | (x ^ y))
1049        == 0
1050}
1051
1052fn valid_email(email: &str) -> bool {
1053    email.contains('@') && !email.contains(|c: char| c.is_whitespace() || c == '|')
1054}
1055
1056/// Secret names for a git host's token and for the LLM key.
1057pub fn git_secret(host: &str) -> String {
1058    format!("git:{}", host.to_lowercase())
1059}
1060
1061pub const LLM_SECRET: &str = "llm_api_key";
1062
1063impl crate::backends::AccountStore for Store {
1064    fn tidy(&self) {
1065        self.prune();
1066    }
1067
1068    /// The member `email`, with deployment admins always present and admin.
1069    fn member(&self, email: &str) -> Result<Option<Who>> {
1070        let email = normalize(email);
1071        if self.admins.contains(&email) {
1072            return Ok(Some(Who {
1073                email,
1074                role: Role::Admin,
1075            }));
1076        }
1077        let role = self.state().users.get(&email).map(|u| u.role);
1078        Ok(role.map(|role| Who { email, role }))
1079    }
1080
1081    fn members(&self) -> Result<Vec<Member>> {
1082        let admins = self.admins.clone();
1083        let st = self.state();
1084        let mut out: Vec<Member> = st
1085            .users
1086            .iter()
1087            .map(|(email, u)| Member {
1088                email: email.clone(),
1089                role: u.role,
1090                fixed: false,
1091                created: u.created.clone(),
1092                last_seen: u.last_seen.clone(),
1093            })
1094            .collect();
1095        for m in &mut out {
1096            if admins.contains(&m.email) {
1097                m.role = Role::Admin;
1098                m.fixed = true;
1099            }
1100        }
1101        for a in admins {
1102            if !out.iter().any(|m| m.email == a) {
1103                out.push(Member {
1104                    last_seen: st.seen.get(&a).cloned(),
1105                    email: a,
1106                    role: Role::Admin,
1107                    fixed: true,
1108                    created: String::new(),
1109                });
1110            }
1111        }
1112        Ok(out)
1113    }
1114
1115    fn set_role(&self, by: &str, email: &str, role: Role) -> Result<()> {
1116        let email = normalize(email);
1117        if self.admins.contains(&email) {
1118            bail!("{email} is an admin by the deployment's settings");
1119        }
1120        let _w = self.write_lock();
1121        let user = self.state().users.get(&email).cloned();
1122        let mut user = user.with_context(|| format!("{email} is not a member"))?;
1123        self.may_manage(by)?;
1124        user.role = role;
1125        self.commit(vec![put("user", &email, &user)])?;
1126        self.audit(by, "role", &format!("{email} {}", role.as_str()));
1127        Ok(())
1128    }
1129
1130    /// Remove a member with their sessions, tokens and invitations.
1131    fn remove(&self, by: &str, email: &str) -> Result<()> {
1132        let email = normalize(email);
1133        if self.admins.contains(&email) {
1134            bail!("{email} is an admin by the deployment's settings");
1135        }
1136        self.may_manage(by)?;
1137        let _w = self.write_lock();
1138        let mut changes = vec![del("user", &email), del("code", &email)];
1139        {
1140            let st = self.state();
1141            changes.extend(
1142                st.sessions
1143                    .iter()
1144                    .filter(|(_, s)| s.email == email)
1145                    .map(|(k, _)| del("session", k)),
1146            );
1147            changes.extend(
1148                st.tokens
1149                    .iter()
1150                    .filter(|(_, t)| t.email == email)
1151                    .map(|(k, _)| del("token", k)),
1152            );
1153            changes.extend(
1154                st.invites
1155                    .iter()
1156                    .filter(|(_, i)| i.email == email)
1157                    .map(|(k, _)| del("invite", k)),
1158            );
1159            changes.extend(
1160                st.links
1161                    .iter()
1162                    .filter(|(_, l)| l.email == email)
1163                    .map(|(k, _)| del("link", k)),
1164            );
1165        }
1166        self.commit(changes)?;
1167        let prefix = format!("code|{email}|");
1168        self.lock()
1169            .failures
1170            .retain(|(k, _)| !k.starts_with(&prefix));
1171        self.audit(by, "remove", &email);
1172        Ok(())
1173    }
1174
1175    /// An invitation for `email` only: a link token, or with `code` a code to hand over.
1176    fn invite(&self, by: &str, email: &str, role: Role, code: bool) -> Result<String> {
1177        let email = normalize(email);
1178        self.may_manage(by)?;
1179        anyhow::ensure!(
1180            email.contains('@') && !email.contains(char::is_whitespace),
1181            "that is not an email address"
1182        );
1183        let (token, h) = if code {
1184            let c = new_code();
1185            let h = code_hash(&c);
1186            (c, h)
1187        } else {
1188            let t = new_token("inv_");
1189            let h = hash(&t);
1190            (t, h)
1191        };
1192        let _w = self.write_lock();
1193        let mut changes: Vec<Change> = self
1194            .state()
1195            .invites
1196            .iter()
1197            .filter(|(_, i)| i.email == email)
1198            .map(|(k, _)| del("invite", k))
1199            .collect();
1200        changes.push(put(
1201            "invite",
1202            &h,
1203            &Invitation {
1204                email: email.clone(),
1205                role,
1206                created_by: by.to_string(),
1207                created: now(),
1208                expires: (Utc::now() + Duration::days(INVITE_DAYS)).to_rfc3339(),
1209            },
1210        ));
1211        self.commit(changes)?;
1212        self.audit(by, "invite", &format!("{email} {}", role.as_str()));
1213        Ok(token)
1214    }
1215
1216    fn invites(&self) -> Result<Vec<Invite>> {
1217        let t = now();
1218        let mut out: Vec<(String, Invite)> = self
1219            .state()
1220            .invites
1221            .values()
1222            .filter(|i| i.expires > t)
1223            .map(|i| {
1224                (
1225                    i.created.clone(),
1226                    Invite {
1227                        email: i.email.clone(),
1228                        role: i.role,
1229                        created_by: i.created_by.clone(),
1230                        expires: i.expires.clone(),
1231                    },
1232                )
1233            })
1234            .collect();
1235        out.sort_by(|a, b| b.0.cmp(&a.0));
1236        Ok(out.into_iter().map(|(_, i)| i).collect())
1237    }
1238
1239    fn revoke_invite(&self, by: &str, email: &str) -> Result<()> {
1240        let email = normalize(email);
1241        self.may_manage(by)?;
1242        let _w = self.write_lock();
1243        let changes = self
1244            .state()
1245            .invites
1246            .iter()
1247            .filter(|(_, i)| i.email == email)
1248            .map(|(k, _)| del("invite", k))
1249            .collect();
1250        self.commit(changes)?;
1251        self.audit(by, "revoke-invite", &email);
1252        Ok(())
1253    }
1254
1255    /// A sign-in link token for a member, or `None` when `email` is not one.
1256    fn sign_in_link(&self, email: &str) -> Result<Option<String>> {
1257        let Some(who) = self.member(email)? else {
1258            return Ok(None);
1259        };
1260        let hour = (Utc::now() - Duration::hours(1)).to_rfc3339();
1261        let recent = self
1262            .state()
1263            .links
1264            .values()
1265            .filter(|l| l.email == who.email && l.created > hour)
1266            .count() as i64;
1267        // Answered like any other address, so the limit does not reveal who is a member.
1268        if recent >= MAX_LINKS_PER_HOUR {
1269            crate::ui::warn(&format!(
1270                "a member asked for more than {MAX_LINKS_PER_HOUR} sign-in links in an hour"
1271            ));
1272            return Ok(None);
1273        }
1274        let token = new_token("lnk_");
1275        let link = Link {
1276            email: who.email,
1277            created: now(),
1278            expires: (Utc::now() + Duration::minutes(LINK_MINUTES)).to_rfc3339(),
1279        };
1280        self.commit(vec![put("link", &hash(&token), &link)])?;
1281        Ok(Some(token))
1282    }
1283
1284    /// Exchange a sign-in link, an invitation or the admin token for a new session; `client`
1285    /// limits how often one place may guess.
1286    fn redeem(&self, token: &str, client: &str) -> Result<(String, Who)> {
1287        let token = token.trim();
1288        let from = format!("redeem|{client}");
1289        anyhow::ensure!(
1290            self.failures(&from) < MAX_REDEEM_FAILED,
1291            "Too many attempts from here; try again in {LOCK_MINUTES} minutes"
1292        );
1293        if let Some(admin) = &self.admin_token {
1294            if constant_eq(token, admin) {
1295                let email = self
1296                    .admins
1297                    .first()
1298                    .cloned()
1299                    .context("set COMINDLY_ADMIN_EMAILS to sign in with the admin token")?;
1300                self.audit(&email, "sign-in", "admin token");
1301                return self.session_for(&email);
1302            }
1303        }
1304        let h = hash(token);
1305        let t = now();
1306        let _w = self.write_lock();
1307        let link = self
1308            .state()
1309            .links
1310            .get(&h)
1311            .filter(|l| l.expires > t)
1312            .map(|l| l.email.clone());
1313        if let Some(email) = link {
1314            self.commit(vec![del("link", &h)])?;
1315            drop(_w);
1316            self.audit(&email, "sign-in", "email link");
1317            return self.session_for(&email);
1318        }
1319        let invite = self
1320            .state()
1321            .invites
1322            .get(&h)
1323            .filter(|i| i.expires > t)
1324            .cloned();
1325        if let Some(inv) = invite {
1326            let mut changes = vec![del("invite", &h)];
1327            if !self.state().users.contains_key(&inv.email) {
1328                changes.push(put(
1329                    "user",
1330                    &inv.email,
1331                    &User {
1332                        role: inv.role,
1333                        created: t,
1334                        last_seen: None,
1335                    },
1336                ));
1337            }
1338            self.commit(changes)?;
1339            drop(_w);
1340            self.audit(&inv.email, "join", inv.role.as_str());
1341            return self.session_for(&inv.email);
1342        }
1343        drop(_w);
1344        self.fail(&from);
1345        bail!("this link has expired or was already used; ask for a new one")
1346    }
1347
1348    /// Sign in with an email address and a code: an invitation code, which also issues the
1349    /// person's own sign-in code (returned once), or that sign-in code.
1350    /// `client` identifies where the attempt comes from: wrong codes pause that address for that
1351    /// client only, so nobody can lock another person out.
1352    fn sign_in_code(
1353        &self,
1354        email: &str,
1355        code: &str,
1356        client: &str,
1357    ) -> Result<(String, Who, Option<String>)> {
1358        let email = normalize(email);
1359        anyhow::ensure!(valid_email(&email), "Wrong email or code");
1360        let from = format!("code|{email}|{client}");
1361        anyhow::ensure!(
1362            self.failures(&from) < MAX_FAILED,
1363            "Too many wrong codes for this address; try again in {LOCK_MINUTES} minutes"
1364        );
1365        let h = code_hash(code);
1366        let t = now();
1367        let _w = self.write_lock();
1368        let invite = self
1369            .state()
1370            .invites
1371            .get(&h)
1372            .filter(|i| i.email == email && i.expires > t)
1373            .cloned();
1374        if let Some(inv) = invite {
1375            let personal = new_code();
1376            let mut changes = vec![
1377                del("invite", &h),
1378                put(
1379                    "code",
1380                    &email,
1381                    &Code {
1382                        hash: code_hash(&personal),
1383                        created: t.clone(),
1384                    },
1385                ),
1386            ];
1387            if !self.state().users.contains_key(&email) {
1388                changes.push(put(
1389                    "user",
1390                    &email,
1391                    &User {
1392                        role: inv.role,
1393                        created: t,
1394                        last_seen: None,
1395                    },
1396                ));
1397            }
1398            self.commit(changes)?;
1399            drop(_w);
1400            self.forgive(&from);
1401            self.audit(&email, "join", inv.role.as_str());
1402            let (session, who) = self.session_for(&email)?;
1403            return Ok((session, who, Some(personal)));
1404        }
1405        drop(_w);
1406        let known = self
1407            .state()
1408            .codes
1409            .get(&email)
1410            .is_some_and(|c| constant_eq(&c.hash, &h));
1411        if known && self.member(&email)?.is_some() {
1412            self.forgive(&from);
1413            self.audit(&email, "sign-in", "code");
1414            let (session, who) = self.session_for(&email)?;
1415            return Ok((session, who, None));
1416        }
1417        self.fail(&from);
1418        bail!("Wrong email or code")
1419    }
1420
1421    /// A member, someone invited (who joins with the invitation's role) or, with `join`, a
1422    /// newcomer who joins as a viewer; the identity provider has already checked who they are.
1423    fn sign_in_verified(&self, email: &str, how: &str, join: bool) -> Result<(String, Who)> {
1424        let email = normalize(email);
1425        if self.member(&email)?.is_some() {
1426            self.audit(&email, "sign-in", how);
1427            return self.session_for(&email);
1428        }
1429        let who = self.admit(&email, join)?;
1430        self.session_for(&who.email)
1431    }
1432
1433    fn admit(&self, email: &str, join: bool) -> Result<Who> {
1434        let email = normalize(email);
1435        anyhow::ensure!(
1436            valid_email(&email),
1437            "the sign-in did not come with an email address"
1438        );
1439        if let Some(who) = self.member(&email)? {
1440            return Ok(who);
1441        }
1442        let t = now();
1443        let _w = self.write_lock();
1444        let invites: Vec<(String, Invitation)> = self
1445            .state()
1446            .invites
1447            .iter()
1448            .filter(|(_, i)| i.email == email)
1449            .map(|(k, i)| (k.clone(), i.clone()))
1450            .collect();
1451        let role = match invites.iter().find(|(_, i)| i.expires > t) {
1452            Some((_, i)) => i.role,
1453            None if join => Role::Viewer,
1454            None => bail!("The account {email} is not a member here; ask an admin to invite you"),
1455        };
1456        let mut changes: Vec<Change> = invites.iter().map(|(k, _)| del("invite", k)).collect();
1457        changes.push(put(
1458            "user",
1459            &email,
1460            &User {
1461                role,
1462                created: t,
1463                last_seen: None,
1464            },
1465        ));
1466        self.commit(changes)?;
1467        drop(_w);
1468        self.audit(&email, "join", role.as_str());
1469        Ok(Who { email, role })
1470    }
1471
1472    /// Give `email` a new sign-in code; their sessions end, except `keep` (the caller's own).
1473    fn reset_code(&self, by: &str, email: &str, keep: Option<&str>) -> Result<String> {
1474        let email = normalize(email);
1475        anyhow::ensure!(self.member(&email)?.is_some(), "{email} is not a member");
1476        if by != email {
1477            anyhow::ensure!(
1478                !self.admins.contains(&email),
1479                "{email} is an admin by the deployment's settings and resets their own code"
1480            );
1481            self.may_manage(by)?;
1482        }
1483        let code = new_code();
1484        let keep = keep.map(hash).unwrap_or_default();
1485        let mut changes = vec![put(
1486            "code",
1487            &email,
1488            &Code {
1489                hash: code_hash(&code),
1490                created: now(),
1491            },
1492        )];
1493        changes.extend(
1494            self.state()
1495                .sessions
1496                .iter()
1497                .filter(|(k, s)| s.email == email && **k != keep)
1498                .map(|(k, _)| del("session", k)),
1499        );
1500        self.commit(changes)?;
1501        let prefix = format!("code|{email}|");
1502        self.lock()
1503            .failures
1504            .retain(|(k, _)| !k.starts_with(&prefix));
1505        self.audit(by, "reset-code", &email);
1506        Ok(code)
1507    }
1508
1509    fn sign_out(&self, session: &str, everywhere: bool) -> Result<()> {
1510        let h = hash(session);
1511        let (changes, email) = {
1512            let st = self.state();
1513            let email = st.sessions.get(&h).map(|s| s.email.clone());
1514            let changes = match (everywhere, st.sessions.get(&h)) {
1515                (true, Some(s)) => st
1516                    .sessions
1517                    .iter()
1518                    .filter(|(_, x)| x.email == s.email)
1519                    .map(|(k, _)| del("session", k))
1520                    .collect(),
1521                _ => vec![del("session", &h)],
1522            };
1523            (changes, email)
1524        };
1525        self.commit(changes)?;
1526        if let Some(email) = email {
1527            self.audit(
1528                &email,
1529                "sign-out",
1530                if everywhere { "everywhere" } else { "" },
1531            );
1532        }
1533        Ok(())
1534    }
1535
1536    /// A personal access token for agents; its value is returned once.
1537    fn create_token(&self, who: &Who, name: &str, expires_in_days: Option<u32>) -> Result<String> {
1538        let name = name.trim();
1539        anyhow::ensure!(!name.is_empty(), "give the token a name");
1540        if let Some(d) = expires_in_days {
1541            anyhow::ensure!((1..=3650).contains(&d), "a token lasts 1 to 3650 days");
1542        }
1543        let token = new_token("cly_");
1544        let id = uuid::Uuid::new_v4().simple().to_string();
1545        let row = Token {
1546            hash: hash(&token),
1547            email: who.email.clone(),
1548            name: name.to_string(),
1549            created: now(),
1550            last_used: None,
1551            expires: expires_in_days.map(|d| (Utc::now() + Duration::days(d.into())).to_rfc3339()),
1552        };
1553        let mut changes = long_expired(&self.state().tokens, |e| e == who.email);
1554        changes.push(put("token", &id, &row));
1555        self.commit(changes)?;
1556        let lasts = match expires_in_days {
1557            Some(d) => format!("{name} ({d} days)"),
1558            None => format!("{name} (no expiry)"),
1559        };
1560        self.audit(&who.email, "token", &lasts);
1561        Ok(token)
1562    }
1563
1564    fn tokens(&self, who: &Who) -> Result<Vec<AccessToken>> {
1565        let t = now();
1566        let mut out: Vec<AccessToken> = self
1567            .state()
1568            .tokens
1569            .iter()
1570            .filter(|(_, tok)| tok.email == who.email)
1571            .map(|(id, tok)| AccessToken {
1572                id: id.clone(),
1573                name: tok.name.clone(),
1574                created: tok.created.clone(),
1575                last_used: tok.last_used.clone(),
1576                expires: tok.expires.clone(),
1577                expired: tok.expired(&t),
1578            })
1579            .collect();
1580        out.sort_by(|a, b| b.created.cmp(&a.created));
1581        Ok(out)
1582    }
1583
1584    fn revoke_token(&self, who: &Who, id: &str) -> Result<()> {
1585        let mine = self
1586            .state()
1587            .tokens
1588            .get(id)
1589            .filter(|t| t.email == who.email)
1590            .map(|t| t.name.clone());
1591        if let Some(name) = mine {
1592            self.commit(vec![del("token", id)])?;
1593            self.audit(&who.email, "revoke-token", &name);
1594        }
1595        Ok(())
1596    }
1597
1598    fn revoke_own_token(&self, token: &str) -> Result<bool> {
1599        let h = hash(token);
1600        let found = self
1601            .state()
1602            .tokens
1603            .iter()
1604            .find(|(_, t)| t.hash == h)
1605            .map(|(id, t)| (id.clone(), t.email.clone()));
1606        let Some((id, email)) = found else {
1607            return Ok(false);
1608        };
1609        self.commit(vec![del("token", &id)])?;
1610        self.audit(&email, "revoke-token", &id);
1611        Ok(true)
1612    }
1613}
1614
1615impl crate::backends::AuditLog for Store {
1616    fn record(&self, actor: &str, action: &str, detail: &str) {
1617        self.audit(actor, action, detail);
1618    }
1619
1620    fn audit_entries(&self, query: &AuditQuery) -> Result<AuditPage> {
1621        let limit = query.limit.unwrap_or(AUDIT_PAGE).clamp(1, AUDIT_PAGE_MAX);
1622        let before = query.before.as_deref().and_then(|b| b.split_once('|'));
1623        let actor = query
1624            .actor
1625            .as_deref()
1626            .map(normalize)
1627            .filter(|a| !a.is_empty());
1628        let action = query
1629            .action
1630            .as_deref()
1631            .map(str::trim)
1632            .filter(|a| !a.is_empty());
1633        let mut rows: Vec<(String, AuditEntry)> = self
1634            .audit_rows()?
1635            .into_iter()
1636            .map(|(key, e, ..)| (key, e))
1637            .filter(|(key, e)| {
1638                before.is_none_or(|(at, k)| (e.at.as_str(), key.as_str()) < (at, k))
1639                    && actor.as_ref().is_none_or(|a| normalize(&e.actor) == *a)
1640                    && action.is_none_or(|a| e.action == a)
1641            })
1642            .collect();
1643        rows.sort_unstable_by(|a, b| (&b.1.at, &b.0).cmp(&(&a.1.at, &a.0)));
1644        let next = (rows.len() > limit).then(|| {
1645            let (key, e) = &rows[limit - 1];
1646            format!("{}|{key}", e.at)
1647        });
1648        rows.truncate(limit);
1649        Ok(AuditPage {
1650            entries: rows.into_iter().map(|(_, e)| e).collect(),
1651            next,
1652        })
1653    }
1654}
1655
1656impl crate::backends::DeviceSignIn for Store {
1657    fn device_start(&self, client: &str, from: &str) -> Result<(String, String)> {
1658        let key = format!("device|{from}");
1659        anyhow::ensure!(
1660            self.failures(&key) < MAX_DEVICE_STARTS,
1661            "Too many sign-ins started from here; try again in {LOCK_MINUTES} minutes"
1662        );
1663        self.fail(&key);
1664        let client: String = client.trim().chars().take(80).collect();
1665        anyhow::ensure!(!client.is_empty(), "name the client signing in");
1666        let device_code = new_token("dev_");
1667        let user_code = new_code()[..9].to_string();
1668        let row = Device {
1669            user_code: code_hash(&user_code),
1670            client,
1671            created: now(),
1672            expires: (Utc::now() + Duration::minutes(DEVICE_MINUTES)).to_rfc3339(),
1673            approved_by: None,
1674            denied: false,
1675            token: None,
1676        };
1677        self.commit(vec![put("device", &hash(&device_code), &row)])?;
1678        Ok((device_code, user_code))
1679    }
1680
1681    fn device_info(&self, user_code: &str) -> Option<DeviceRequest> {
1682        let (h, t) = (code_hash(user_code), now());
1683        self.state()
1684            .devices
1685            .values()
1686            .find(|d| d.user_code == h && d.expires > t && d.approved_by.is_none() && !d.denied)
1687            .map(|d| DeviceRequest {
1688                client: d.client.clone(),
1689                expires: d.expires.clone(),
1690            })
1691    }
1692
1693    fn device_approve(&self, who: &Who, user_code: &str, approve: bool) -> Result<()> {
1694        let key = format!("approve|{}", who.email);
1695        anyhow::ensure!(
1696            self.failures(&key) < MAX_FAILED,
1697            "Too many wrong codes; try again in {LOCK_MINUTES} minutes"
1698        );
1699        let (h, t) = (code_hash(user_code), now());
1700        let _w = self.write_lock();
1701        let found = self
1702            .state()
1703            .devices
1704            .iter()
1705            .find(|(_, d)| {
1706                d.user_code == h && d.expires > t && d.approved_by.is_none() && !d.denied
1707            })
1708            .map(|(k, d)| (k.clone(), d.clone()));
1709        let Some((id, mut row)) = found else {
1710            self.fail(&key);
1711            bail!("That code is not waiting for approval; start the sign-in again");
1712        };
1713        if approve {
1714            let token = self.create_token(who, &row.client, Some(TOKEN_DAYS))?;
1715            let (sealed, nonce) = seal(&self.key, &format!("device:{id}"), &token)?;
1716            row.token = Some((hex(&sealed), hex(&nonce)));
1717            row.approved_by = Some(who.email.clone());
1718        } else {
1719            row.denied = true;
1720        }
1721        self.commit(vec![put("device", &id, &row)])?;
1722        self.audit(
1723            &who.email,
1724            if approve {
1725                "device-approved"
1726            } else {
1727                "device-denied"
1728            },
1729            &row.client,
1730        );
1731        Ok(())
1732    }
1733
1734    fn device_poll(&self, device_code: &str) -> Result<DevicePoll> {
1735        let id = hash(device_code.trim());
1736        let _w = self.write_lock();
1737        let Some(row) = self.state().devices.get(&id).cloned() else {
1738            return Ok(DevicePoll::Expired);
1739        };
1740        if row.denied {
1741            self.commit(vec![del("device", &id)])?;
1742            return Ok(DevicePoll::Denied);
1743        }
1744        if let (Some(email), Some((sealed, nonce))) = (&row.approved_by, &row.token) {
1745            let token = unhex(sealed)
1746                .zip(unhex(nonce))
1747                .and_then(|(s, n)| open(&self.key, &format!("device:{id}"), &s, &n))
1748                .context("the issued token cannot be read; start the sign-in again")?;
1749            self.commit(vec![del("device", &id)])?;
1750            let h = hash(&token);
1751            let expires = self
1752                .state()
1753                .tokens
1754                .values()
1755                .find(|t| t.hash == h)
1756                .and_then(|t| t.expires.clone());
1757            return Ok(DevicePoll::Approved {
1758                token,
1759                email: email.clone(),
1760                expires,
1761            });
1762        }
1763        Ok(if row.expires < now() {
1764            DevicePoll::Expired
1765        } else {
1766            DevicePoll::Pending
1767        })
1768    }
1769}
1770
1771impl crate::backends::Authenticator for Store {
1772    /// The member behind a session cookie or, failing that, a bearer token, and which it was.
1773    fn authenticate(
1774        &self,
1775        session: Option<&str>,
1776        bearer: Option<&str>,
1777    ) -> Result<Option<(Who, Via)>> {
1778        let t = now();
1779        let mut found: Option<(String, Via)> = None;
1780        let mut used: Option<(String, Token)> = None;
1781        if let Some(s) = session {
1782            found = self
1783                .state()
1784                .sessions
1785                .get(&hash(s))
1786                .filter(|s| s.expires > t)
1787                .map(|s| (s.email.clone(), Via::Session));
1788        }
1789        if found.is_none() {
1790            if let Some(b) = bearer {
1791                if self
1792                    .admin_token
1793                    .as_deref()
1794                    .is_some_and(|a| constant_eq(b, a))
1795                {
1796                    found = self.admins.first().map(|e| (e.clone(), Via::AdminToken));
1797                } else {
1798                    let h = hash(b);
1799                    let st = self.state();
1800                    let live = st
1801                        .tokens
1802                        .iter()
1803                        .find(|(_, k)| k.hash == h && !k.expired(&t));
1804                    if let Some((id, tok)) = live {
1805                        found = Some((tok.email.clone(), Via::Token));
1806                        used = Some((id.clone(), tok.clone()));
1807                    }
1808                }
1809            }
1810        }
1811        let Some((email, via)) = found else {
1812            return Ok(None);
1813        };
1814        let who = self.member(&email)?;
1815        if who.is_some() {
1816            // Recorded at most every few minutes, so requests do not each write to storage.
1817            let since = (Utc::now() - Duration::minutes(5)).to_rfc3339();
1818            let mut changes = Vec::new();
1819            if let Some((id, mut tok)) =
1820                used.filter(|(_, t)| t.last_used.as_ref().is_none_or(|u| *u < since))
1821            {
1822                tok.last_used = Some(t.clone());
1823                changes.push(put("token", &id, &tok));
1824            }
1825            let (user, seen) = {
1826                let st = self.state();
1827                (st.users.get(&email).cloned(), st.seen.get(&email).cloned())
1828            };
1829            if let Some(mut u) = user {
1830                if u.last_seen.as_ref().is_none_or(|s| *s < since) {
1831                    u.last_seen = Some(t);
1832                    changes.push(put("user", &email, &u));
1833                }
1834            } else if seen.is_none_or(|s| s < since) {
1835                changes.push(put("seen", &email, &t));
1836            }
1837            if let Err(e) = self.commit(changes) {
1838                crate::ui::warn(&format!("recording last use: {e:#}"));
1839            }
1840        }
1841        Ok(who.map(|w| (w, via)))
1842    }
1843}
1844
1845impl crate::backends::SecretStore for Store {
1846    /// Store `value` under `name` (an empty value removes it), sealed with `COMINDLY_SECRET_KEY`.
1847    fn set_secret(&self, by: &str, name: &str, value: &str) -> Result<()> {
1848        let value = value.trim();
1849        if value.is_empty() {
1850            self.commit(vec![del("secret", name)])?;
1851            self.audit(by, "secret-removed", name);
1852            return Ok(());
1853        }
1854        let row = sealed(&self.key, name, value, by)?;
1855        self.commit(vec![put("secret", name, &row)])?;
1856        self.audit(by, "secret-set", name);
1857        Ok(())
1858    }
1859
1860    fn secret(&self, name: &str) -> Option<String> {
1861        let row = self.state().secrets.get(name).cloned()?;
1862        unseal(&self.key, name, &row)
1863    }
1864
1865    fn secret_info(&self, name: &str) -> Option<SecretInfo> {
1866        self.state().secrets.get(name).map(|s| SecretInfo {
1867            name: name.to_string(),
1868            source: "stored".into(),
1869            hint: Some(s.hint.clone()),
1870            updated: Some(s.updated.clone()),
1871            updated_by: Some(s.updated_by.clone()),
1872        })
1873    }
1874
1875    /// Names of stored secrets.
1876    fn secret_names(&self) -> Vec<String> {
1877        self.state().secrets.keys().cloned().collect()
1878    }
1879}
1880
1881impl crate::backends::SettingsStore for Store {
1882    fn repos(&self) -> Vec<crate::config::RepoEntry> {
1883        self.state()
1884            .config
1885            .get(REPOS)
1886            .and_then(|j| serde_json::from_str(j).ok())
1887            .unwrap_or_default()
1888    }
1889
1890    fn set_repos(&self, by: &str, repos: &[crate::config::RepoEntry]) -> Result<()> {
1891        self.commit(vec![put("config", REPOS, &serde_json::to_string(repos)?)])?;
1892        self.audit(by, "repos", &repos.len().to_string());
1893        Ok(())
1894    }
1895
1896    fn legacy_config(&self) -> Option<String> {
1897        self.state().config.get(CONFIG).cloned()
1898    }
1899
1900    fn drop_legacy_config(&self) -> Result<()> {
1901        self.commit(vec![del("config", CONFIG)])
1902    }
1903
1904    fn log_skip(&self) -> Option<String> {
1905        self.state().config.get(LOG_SKIP).cloned()
1906    }
1907
1908    fn set_log_skip(&self, by: &str, skip: &str) -> Result<()> {
1909        self.commit(vec![put("config", LOG_SKIP, &skip.to_string())])?;
1910        self.audit(by, "logging", if skip.is_empty() { "all" } else { skip });
1911        Ok(())
1912    }
1913}
1914
1915impl crate::jobs::JobStore for Store {
1916    fn save(&self, job: &crate::jobs::JobRecord, events: &[crate::events::Event]) -> Result<()> {
1917        let mut changes = vec![
1918            put("job", &job.id, job),
1919            put("job-events", &job.id, &events),
1920        ];
1921        {
1922            let st = self.state();
1923            let mut ids: Vec<(&String, &String)> = st
1924                .jobs
1925                .iter()
1926                .filter(|(id, _)| **id != job.id)
1927                .map(|(id, j)| (&j.created, id))
1928                .collect();
1929            ids.sort_unstable_by(|a, b| b.cmp(a));
1930            for (_, id) in ids.into_iter().skip(crate::jobs::MAX_JOBS - 1) {
1931                changes.extend([del("job", id), del("job-events", id)]);
1932            }
1933        }
1934        let _w = self.write_lock();
1935        self.commit(changes)
1936    }
1937
1938    fn get(&self, id: &str) -> Option<(crate::jobs::JobRecord, Vec<crate::events::Event>)> {
1939        let st = self.state();
1940        let job = st.jobs.get(id)?.clone();
1941        Some((job, st.job_events.get(id).cloned().unwrap_or_default()))
1942    }
1943
1944    fn list(&self, limit: usize) -> Vec<crate::jobs::JobRecord> {
1945        let mut jobs: Vec<_> = self.state().jobs.values().cloned().collect();
1946        jobs.sort_unstable_by(|a, b| b.created.cmp(&a.created));
1947        jobs.truncate(limit);
1948        jobs
1949    }
1950}
1951
1952#[cfg(test)]
1953mod tests {
1954    use super::*;
1955
1956    fn temp_store() -> ((), Store) {
1957        let dir = std::env::temp_dir().join(format!("comindly-server-{}", uuid::Uuid::new_v4()));
1958        ((), {
1959            let mut s = Store::open_keyed(dir.to_str().unwrap(), "test key", None).unwrap();
1960            s.admins = vec!["a".into(), "a@x.org".into(), "admin@x.org".into()];
1961            s
1962        })
1963    }
1964
1965    #[test]
1966    fn invitations_are_single_use_and_tied_to_their_email() {
1967        let (_d, s) = temp_store();
1968        let token = s
1969            .invite("admin@x.org", "Dev@X.org ", Role::Member, false)
1970            .unwrap();
1971        let (session, who) = s.redeem(&token, "10.0.0.1").unwrap();
1972        assert_eq!(who.email, "dev@x.org");
1973        assert_eq!(who.role, Role::Member);
1974        assert!(s.redeem(&token, "10.0.0.1").is_err());
1975        let (back, _) = s.authenticate(Some(&session), None).unwrap().unwrap();
1976        assert_eq!(back.email, "dev@x.org");
1977        s.sign_out(&session, false).unwrap();
1978        assert!(s.authenticate(Some(&session), None).unwrap().is_none());
1979    }
1980
1981    #[test]
1982    fn sign_in_methods_combine_and_need_their_setup() {
1983        use SignIn::*;
1984        let m = |set, smtp, oidc| sign_in_methods(set, smtp, oidc);
1985        assert_eq!(m(None, false, false).unwrap(), vec![Code]);
1986        assert_eq!(m(None, true, true).unwrap(), vec![Oidc, Email]);
1987        assert_eq!(
1988            m(Some("code, email,oidc"), true, true).unwrap(),
1989            vec![Oidc, Email, Code]
1990        );
1991        assert!(m(Some("email"), false, true).is_err());
1992        assert!(m(Some("oidc,code"), true, false).is_err());
1993        assert!(m(Some("password"), true, true).is_err());
1994        assert!(m(Some(","), true, true).is_err());
1995    }
1996
1997    #[test]
1998    fn a_provider_sign_in_admits_members_the_invited_and_allowed_domains_only() {
1999        let (_d, s) = temp_store();
2000        assert!(s.sign_in_verified("stranger@x.org", "oidc", false).is_err());
2001        assert!(s.member("stranger@x.org").unwrap().is_none());
2002
2003        let (_, who) = s.sign_in_verified("viewer@x.org", "oidc", true).unwrap();
2004        assert_eq!(who.role, Role::Viewer);
2005
2006        s.invite("admin@x.org", "dev@x.org", Role::Member, true)
2007            .unwrap();
2008        let (session, who) = s.sign_in_verified("Dev@X.org", "oidc", false).unwrap();
2009        assert_eq!((who.email.as_str(), who.role), ("dev@x.org", Role::Member));
2010        assert!(s.invites().unwrap().is_empty(), "the invitation is used up");
2011        assert!(s.authenticate(Some(&session), None).unwrap().is_some());
2012
2013        s.set_role("admin@x.org", "dev@x.org", Role::Admin).unwrap();
2014        let (_, who) = s.sign_in_verified("dev@x.org", "oidc", false).unwrap();
2015        assert_eq!(who.role, Role::Admin, "a member keeps the role given here");
2016        assert!(s.sign_in_verified("", "oidc", true).is_err());
2017    }
2018
2019    #[test]
2020    fn the_gateway_admits_the_invited_without_a_session() {
2021        let (_d, s) = temp_store();
2022        assert!(s.admit("stranger@x.org", false).is_err());
2023        s.invite("admin@x.org", "dev@x.org", Role::Member, true)
2024            .unwrap();
2025        let who = s.admit("Dev@X.org", false).unwrap();
2026        assert_eq!((who.email.as_str(), who.role), ("dev@x.org", Role::Member));
2027        assert!(s.invites().unwrap().is_empty(), "the invitation is used up");
2028        assert_eq!(s.admit("dev@x.org", false).unwrap().role, Role::Member);
2029        assert_eq!(s.admit("new@x.org", true).unwrap().role, Role::Viewer);
2030    }
2031
2032    #[test]
2033    fn a_command_line_sign_in_is_approved_once_and_collected_once() {
2034        use crate::backends::DeviceSignIn;
2035        let (_d, s) = temp_store();
2036        let invite = s.invite("a@x.org", "m@x.org", Role::Member, false).unwrap();
2037        let (_, who) = s.redeem(&invite, "1.2.3.4").unwrap();
2038        let (device, user) = s.device_start("comindly CLI on laptop", "1.2.3.4").unwrap();
2039        assert_eq!(s.device_poll(&device).unwrap(), DevicePoll::Pending);
2040        assert_eq!(
2041            s.device_info(&user.to_lowercase()).unwrap().client,
2042            "comindly CLI on laptop"
2043        );
2044        assert!(s.device_approve(&who, "AAAA-AAAA", true).is_err());
2045        s.device_approve(&who, &user, true).unwrap();
2046        assert!(s.device_info(&user).is_none(), "approved once");
2047        let DevicePoll::Approved { token, email, .. } = s.device_poll(&device).unwrap() else {
2048            panic!("not approved");
2049        };
2050        assert_eq!(email, "m@x.org");
2051        assert!(s.authenticate(None, Some(&token)).unwrap().is_some());
2052        assert_eq!(
2053            s.device_poll(&device).unwrap(),
2054            DevicePoll::Expired,
2055            "collected once"
2056        );
2057
2058        let (device, user) = s.device_start("other", "1.2.3.4").unwrap();
2059        s.device_approve(&who, &user, false).unwrap();
2060        assert_eq!(s.device_poll(&device).unwrap(), DevicePoll::Denied);
2061    }
2062
2063    #[test]
2064    fn only_admins_manage_people() {
2065        let (_d, s) = temp_store();
2066        let join = |email: &str, role| {
2067            let inv = s.invite("a", email, role, false).unwrap();
2068            s.redeem(&inv, "1.2.3.4").unwrap().1
2069        };
2070        let admin = join("adm@x.org", Role::Admin);
2071        join("mem@x.org", Role::Member);
2072        join("view@x.org", Role::Viewer);
2073        assert!(s
2074            .invite(&admin.email, "x@x.org", Role::Admin, false)
2075            .is_ok());
2076        assert!(s.set_role(&admin.email, "mem@x.org", Role::Viewer).is_ok());
2077        assert!(s.set_role("mem@x.org", "view@x.org", Role::Member).is_err());
2078        assert!(s
2079            .invite("view@x.org", "y@x.org", Role::Viewer, false)
2080            .is_err());
2081        assert!(s.remove("mem@x.org", "adm@x.org").is_err());
2082        assert!(
2083            s.remove(&admin.email, "a").is_err(),
2084            "deployment admins stay"
2085        );
2086        assert!(s.remove(&admin.email, "view@x.org").is_ok());
2087    }
2088
2089    #[test]
2090    fn sign_in_links_only_for_members_and_once() {
2091        let (_d, s) = temp_store();
2092        assert!(s.sign_in_link("stranger@x.org").unwrap().is_none());
2093        let inv = s.invite("a", "m@x.org", Role::Member, false).unwrap();
2094        s.redeem(&inv, "10.0.0.1").unwrap();
2095        let link = s.sign_in_link("m@x.org").unwrap().unwrap();
2096        assert!(s.redeem(&link, "10.0.0.1").is_ok());
2097        assert!(s.redeem(&link, "10.0.0.1").is_err());
2098    }
2099
2100    #[test]
2101    fn removing_a_member_ends_their_tokens() {
2102        let (_d, s) = temp_store();
2103        let inv = s.invite("a", "m@x.org", Role::Member, false).unwrap();
2104        let (_, who) = s.redeem(&inv, "10.0.0.1").unwrap();
2105        let token = s.create_token(&who, "laptop", None).unwrap();
2106        assert!(s.authenticate(None, Some(&token)).unwrap().is_some());
2107        s.remove("a", "m@x.org").unwrap();
2108        assert!(s.authenticate(None, Some(&token)).unwrap().is_none());
2109    }
2110
2111    #[test]
2112    fn a_token_revokes_itself_and_no_other() {
2113        let (_d, s) = temp_store();
2114        let inv = s.invite("a", "m@x.org", Role::Member, false).unwrap();
2115        let (_, who) = s.redeem(&inv, "10.0.0.1").unwrap();
2116        let laptop = s.create_token(&who, "laptop", None).unwrap();
2117        let ci = s.create_token(&who, "ci", None).unwrap();
2118        assert!(s.revoke_own_token(&laptop).unwrap());
2119        assert!(s.authenticate(None, Some(&laptop)).unwrap().is_none());
2120        assert!(s.authenticate(None, Some(&ci)).unwrap().is_some());
2121        assert!(!s.revoke_own_token(&laptop).unwrap(), "already gone");
2122        assert!(!s.revoke_own_token("cly_unknown").unwrap());
2123    }
2124
2125    #[test]
2126    fn invitation_codes_need_their_own_address_and_give_a_sign_in_code() {
2127        let (_d, s) = temp_store();
2128        let invite = s.invite("a", "m@x.org", Role::Member, true).unwrap();
2129        assert!(s.sign_in_code("other@x.org", &invite, "10.0.0.1").is_err());
2130        let typed = invite.to_lowercase().replace('-', " ");
2131        let (_, who, personal) = s.sign_in_code("M@x.org", &typed, "10.0.0.1").unwrap();
2132        assert_eq!(who.email, "m@x.org");
2133        let personal = personal.unwrap();
2134        assert!(s.sign_in_code("m@x.org", &invite, "10.0.0.1").is_err());
2135        let (_, _, again) = s.sign_in_code("m@x.org", &personal, "10.0.0.1").unwrap();
2136        assert!(again.is_none());
2137    }
2138
2139    #[test]
2140    fn a_reset_code_replaces_the_old_one_and_ends_other_sessions() {
2141        let (_d, s) = temp_store();
2142        let invite = s.invite("a", "m@x.org", Role::Member, true).unwrap();
2143        let (mine, _, old) = s.sign_in_code("m@x.org", &invite, "10.0.0.1").unwrap();
2144        let (elsewhere, _, _) = s
2145            .sign_in_code("m@x.org", old.as_ref().unwrap(), "10.0.0.1")
2146            .unwrap();
2147        let new = s.reset_code("m@x.org", "m@x.org", Some(&mine)).unwrap();
2148        assert!(s.authenticate(Some(&mine), None).unwrap().is_some());
2149        assert!(s.authenticate(Some(&elsewhere), None).unwrap().is_none());
2150        assert!(s
2151            .sign_in_code("m@x.org", old.as_ref().unwrap(), "10.0.0.1")
2152            .is_err());
2153        assert!(s.sign_in_code("m@x.org", &new, "10.0.0.1").is_ok());
2154    }
2155
2156    #[test]
2157    fn wrong_codes_lock_the_address_for_that_client() {
2158        let (_d, s) = temp_store();
2159        let invite = s.invite("a", "m@x.org", Role::Member, true).unwrap();
2160        for _ in 0..MAX_FAILED {
2161            assert!(s.sign_in_code("m@x.org", "AAAA-AAAA", "10.0.0.1").is_err());
2162        }
2163        let e = s
2164            .sign_in_code("m@x.org", &invite, "10.0.0.1")
2165            .unwrap_err()
2166            .to_string();
2167        assert!(e.contains("Too many"), "{e}");
2168        assert!(s.sign_in_code("m@x.org", &invite, "10.0.0.2").is_ok());
2169    }
2170
2171    #[test]
2172    fn accounts_survive_a_restart_and_rows_without_the_key_are_ignored() {
2173        let dir = std::env::temp_dir().join(format!("comindly-server-{}", uuid::Uuid::new_v4()));
2174        let root = dir.to_str().unwrap();
2175        let mut s = Store::open_keyed(root, "key one", None).unwrap();
2176        s.admins = vec!["admin@x.org".into()];
2177        let code = s
2178            .invite("admin@x.org", "dev@x.org", Role::Member, true)
2179            .unwrap();
2180        let (_, _, personal) = s.sign_in_code("dev@x.org", &code, "1.2.3.4").unwrap();
2181        let token = s
2182            .create_token(&s.member("dev@x.org").unwrap().unwrap(), "agent", None)
2183            .unwrap();
2184        drop(s);
2185
2186        let again = Store::open_keyed(root, "key one", None).unwrap();
2187        assert!(again
2188            .sign_in_code("dev@x.org", &personal.unwrap(), "1.2.3.4")
2189            .is_ok());
2190        assert!(again.authenticate(None, Some(&token)).unwrap().is_some());
2191
2192        let forged = vec![put(
2193            "user",
2194            "eve@x.org",
2195            &User {
2196                role: Role::Admin,
2197                created: now(),
2198                last_seen: None,
2199            },
2200        )];
2201        run(write(
2202            again.uri.clone(),
2203            mac_key("someone else's key"),
2204            forged,
2205        ))
2206        .unwrap();
2207        again.reload().unwrap();
2208        assert!(again.member("eve@x.org").unwrap().is_none());
2209
2210        let rotated = Store::open_keyed(root, "key two", Some("key one")).unwrap();
2211        assert!(rotated.authenticate(None, Some(&token)).unwrap().is_some());
2212        let only_new = Store::open_keyed(root, "key two", None).unwrap();
2213        assert!(only_new.authenticate(None, Some(&token)).unwrap().is_some());
2214    }
2215
2216    /// Move token `id`'s expiry to `days` from now.
2217    fn expire(s: &Store, id: &str, days: i64) {
2218        let mut tok = s.state().tokens.get(id).cloned().unwrap();
2219        tok.expires = Some((Utc::now() + Duration::days(days)).to_rfc3339());
2220        s.commit(vec![put("token", id, &tok)]).unwrap();
2221    }
2222
2223    #[test]
2224    fn expired_tokens_stop_working_stay_listed_then_go() {
2225        let (_d, s) = temp_store();
2226        let who = s.member("a@x.org").unwrap().unwrap();
2227        let token = s.create_token(&who, "agent", Some(30)).unwrap();
2228        let forever = s.create_token(&who, "forever", None).unwrap();
2229        assert!(s.create_token(&who, "zero", Some(0)).is_err());
2230        let list = s.tokens(&who).unwrap();
2231        let agent = list.iter().find(|t| t.name == "agent").unwrap();
2232        assert!(agent.expires.is_some() && !agent.expired);
2233        assert!(list
2234            .iter()
2235            .find(|t| t.name == "forever")
2236            .unwrap()
2237            .expires
2238            .is_none());
2239        let id = agent.id.clone();
2240
2241        expire(&s, &id, -1);
2242        assert!(s.authenticate(None, Some(&token)).unwrap().is_none());
2243        assert!(s.authenticate(None, Some(&forever)).unwrap().is_some());
2244        assert!(s
2245            .tokens(&who)
2246            .unwrap()
2247            .iter()
2248            .any(|t| t.id == id && t.expired));
2249        s.prune();
2250        assert!(
2251            s.tokens(&who).unwrap().iter().any(|t| t.id == id),
2252            "kept a while"
2253        );
2254
2255        expire(&s, &id, -(EXPIRED_TOKEN_DAYS + 1));
2256        s.prune();
2257        let left = s.tokens(&who).unwrap();
2258        assert!(!left.iter().any(|t| t.id == id), "pruned later");
2259        assert_eq!(left.len(), 1);
2260    }
2261
2262    #[test]
2263    fn tokens_stored_without_an_expiry_never_expire() {
2264        let (_d, s) = temp_store();
2265        let row = r#"{"hash":"h","email":"a@x.org","name":"old","created":"2025-01-01T00:00:00+00:00","last_used":null}"#;
2266        let tok: Token = serde_json::from_str(row).unwrap();
2267        assert!(tok.expires.is_none() && !tok.expired(&now()));
2268        let who = s.member("a@x.org").unwrap().unwrap();
2269        s.commit(vec![put("token", "old", &tok)]).unwrap();
2270        let t = s.tokens(&who).unwrap();
2271        assert!(t[0].expires.is_none() && !t[0].expired);
2272    }
2273
2274    #[test]
2275    fn a_command_line_token_expires_by_default() {
2276        use crate::backends::DeviceSignIn;
2277        let (_d, s) = temp_store();
2278        let who = s.member("a@x.org").unwrap().unwrap();
2279        let (device, user) = s.device_start("comindly CLI on laptop", "1.2.3.4").unwrap();
2280        s.device_approve(&who, &user, true).unwrap();
2281        let DevicePoll::Approved { expires, .. } = s.device_poll(&device).unwrap() else {
2282            panic!("not approved");
2283        };
2284        let expires = expires.expect("an expiry");
2285        let in_days = chrono::DateTime::parse_from_rfc3339(&expires)
2286            .unwrap()
2287            .to_utc()
2288            - Utc::now();
2289        assert_eq!(in_days.num_days(), i64::from(TOKEN_DAYS) - 1);
2290    }
2291
2292    #[test]
2293    fn the_audit_log_lists_newest_first_in_pages_and_filters() {
2294        use crate::backends::AuditLog;
2295        let (_d, s) = temp_store();
2296        for i in 0..5 {
2297            s.audit("a@x.org", "secret-set", &format!("n{i}"));
2298        }
2299        s.audit("b@x.org", "repos", "2");
2300        let token = s
2301            .create_token(&s.member("a@x.org").unwrap().unwrap(), "agent", Some(30))
2302            .unwrap();
2303        let all = s.audit_entries(&AuditQuery::default()).unwrap();
2304        assert_eq!(all.entries.len(), 7);
2305        assert!(all.next.is_none());
2306        assert!(all.entries.windows(2).all(|w| w[0].at >= w[1].at));
2307        assert_eq!(all.entries[0].action, "token");
2308        assert!(all.entries.iter().all(|e| !e.detail.contains(&token)));
2309
2310        let first = s
2311            .audit_entries(&AuditQuery {
2312                limit: Some(3),
2313                ..Default::default()
2314            })
2315            .unwrap();
2316        assert_eq!(first.entries.len(), 3);
2317        let second = s
2318            .audit_entries(&AuditQuery {
2319                limit: Some(3),
2320                before: first.next.clone(),
2321                ..Default::default()
2322            })
2323            .unwrap();
2324        let third = s
2325            .audit_entries(&AuditQuery {
2326                limit: Some(3),
2327                before: second.next.clone(),
2328                ..Default::default()
2329            })
2330            .unwrap();
2331        assert!(third.next.is_none());
2332        let paged: Vec<_> = [first, second, third]
2333            .into_iter()
2334            .flat_map(|p| p.entries)
2335            .map(|e| (e.at, e.detail))
2336            .collect();
2337        let whole: Vec<_> = all.entries.into_iter().map(|e| (e.at, e.detail)).collect();
2338        assert_eq!(paged, whole);
2339
2340        let by = |actor: Option<&str>, action: Option<&str>| {
2341            s.audit_entries(&AuditQuery {
2342                actor: actor.map(str::to_string),
2343                action: action.map(str::to_string),
2344                ..Default::default()
2345            })
2346            .unwrap()
2347            .entries
2348            .len()
2349        };
2350        assert_eq!(by(Some("B@x.org"), None), 1);
2351        assert_eq!(by(None, Some("secret-set")), 5);
2352        assert_eq!(by(Some("a@x.org"), Some("repos")), 0);
2353    }
2354
2355    #[test]
2356    fn audit_rows_survive_a_key_rotation() {
2357        use crate::backends::AuditLog;
2358        let dir = std::env::temp_dir().join(format!("comindly-server-{}", uuid::Uuid::new_v4()));
2359        let root = dir.to_str().unwrap();
2360        Store::open_keyed(root, "key one", None)
2361            .unwrap()
2362            .audit("a@x.org", "repos", "1");
2363        drop(Store::open_keyed(root, "key two", Some("key one")).unwrap());
2364        let only_new = Store::open_keyed(root, "key two", None).unwrap();
2365        assert_eq!(
2366            only_new
2367                .audit_entries(&AuditQuery::default())
2368                .unwrap()
2369                .entries
2370                .len(),
2371            1
2372        );
2373    }
2374
2375    #[test]
2376    fn codes_are_twenty_characters_in_groups() {
2377        let c = new_code();
2378        assert_eq!(c.len(), 24);
2379        assert!(c.split('-').all(|g| g.len() == 4));
2380        assert_ne!(c, new_code());
2381    }
2382
2383    #[test]
2384    fn sealed_values_open_only_with_their_key() {
2385        let (sealed, nonce) = seal("key-one", "git:gitlab.com", "glpat-example").unwrap();
2386        assert_eq!(
2387            open("key-one", "git:gitlab.com", &sealed, &nonce).as_deref(),
2388            Some("glpat-example")
2389        );
2390        assert!(open("key-two", "git:gitlab.com", &sealed, &nonce).is_none());
2391        assert!(open("key-one", "llm_api_key", &sealed, &nonce).is_none());
2392        assert_eq!(hint("glpat-example"), "…mple");
2393    }
2394
2395    #[test]
2396    fn values_sealed_by_earlier_versions_still_open() {
2397        // Sealed with chacha20poly1305 0.10.
2398        let unhex = |s: &str| {
2399            (0..s.len())
2400                .step_by(2)
2401                .map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap())
2402                .collect::<Vec<u8>>()
2403        };
2404        let sealed = unhex("04558269f76990598e21e0fa73bff46e3b8778dc6671e982ac0d0096b4");
2405        let nonce = unhex("c7063968b8df63bf60ecae5b3deba9969d937912cc1b4726");
2406        assert_eq!(
2407            open("fixture-key", "git:example.com", &sealed, &nonce).as_deref(),
2408            Some("example-value")
2409        );
2410    }
2411}