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