Skip to main content

smooth_operator_server/
local.rs

1//! The **local deployment flavor** — an embeddable, zero-config server.
2//!
3//! This is the third deployment target alongside `deploy/k8s` (Kubernetes +
4//! Postgres + NATS) and `deploy/sst` (AWS serverless): a self-contained server
5//! with **everything in-memory** and **auth off**, meant to run on a developer
6//! laptop or to be **embedded in-process** by a host (e.g. the smooth daemon).
7//!
8//! It needs no external services — no Postgres, no Redis, no NATS, no AWS — and
9//! no secrets. It is exactly the default-flavor server the binary already boots
10//! when no env is set ([`ServerConfig::from_env`](crate::config::ServerConfig::from_env)
11//! defaults to in-memory storage, in-memory backplane, loopback bind, and admin
12//! disabled), factored here so a host can boot it from code in a few lines
13//! rather than by shelling out and setting env vars.
14//!
15//! ## In-process embed
16//!
17//! ```no_run
18//! # async fn demo() -> anyhow::Result<()> {
19//! // Boot a fully in-memory server on the default loopback addr, in the
20//! // background, and get a handle to its real bound address + a shutdown switch.
21//! let server = smooth_operator_server::local::LocalServer::builder()
22//!     .seed_kb(true) // optional: load the demo knowledge docs
23//!     .spawn()
24//!     .await?;
25//!
26//! println!("local operator on ws://{}/ws", server.addr());
27//! // ... use it (connect a client, run turns) ...
28//!
29//! server.shutdown().await; // graceful stop + join
30//! # Ok(())
31//! # }
32//! ```
33//!
34//! ## Run to completion
35//!
36//! ```no_run
37//! # async fn demo() -> anyhow::Result<()> {
38//! // Or just run it in the foreground until killed (what the binary's no-env
39//! // path effectively does):
40//! smooth_operator_server::local::serve_local("127.0.0.1:8787").await?;
41//! # Ok(())
42//! # }
43//! ```
44//!
45//! ## What "local flavor" pins
46//!
47//! Independent of ambient env, the local flavor always uses:
48//!
49//! - **storage** = in-memory ([`InMemoryStorageAdapter`](smooth_operator_adapter_memory::InMemoryStorageAdapter)),
50//! - **backplane** = in-memory (single-process; no Redis/NATS),
51//! - **auth** = none ([`NoAuthVerifier`](smooth_operator::auth::NoAuthVerifier)) — `/admin` is open, `/ws` boots,
52//! - **widget auth** = permissive ([`PermissiveWidgetAuth`](smooth_operator::widget_auth::PermissiveWidgetAuth)),
53//! - **bind** = a caller-supplied addr (default `127.0.0.1:8787`).
54//!
55//! The LLM gateway is still read from `SMOOAI_GATEWAY_URL` / `SMOOAI_GATEWAY_KEY`
56//! (so a key in the environment enables live turns); with no key, `send_message`
57//! returns a clean protocol `error` exactly as the keyless test path does.
58
59use std::net::SocketAddr;
60use std::sync::Arc;
61
62use anyhow::{Context, Result};
63use tokio::net::TcpListener;
64use tokio::task::JoinHandle;
65
66use smooth_operator::adapter::StorageAdapter;
67use smooth_operator::auth::{AuthVerifier, NoAuthVerifier};
68use smooth_operator::tool_provider::ToolProvider;
69
70use crate::config::{ServerConfig, StorageBackend};
71use crate::server::{build_state, router};
72use crate::state::AppState;
73
74/// The default address the local flavor binds when the caller gives none —
75/// loopback on the canonical WebSocket port, matching
76/// [`config::DEFAULT_BIND`](crate::config::DEFAULT_BIND) +
77/// [`config::DEFAULT_PORT`](crate::config::DEFAULT_PORT).
78pub const DEFAULT_LOCAL_ADDR: &str = "127.0.0.1:8787";
79
80/// Builder for the [local deployment flavor](self): a fully in-memory,
81/// auth-off, single-process server, embeddable in-process.
82///
83/// All knobs are optional — `LocalServer::builder().spawn().await` boots the
84/// default flavor (in-memory everything, loopback `:8787`, no auth, no seed).
85/// Construct with [`LocalServer::builder`].
86#[derive(Clone)]
87pub struct LocalServerBuilder {
88    addr: SocketAddr,
89    seed_kb: bool,
90    config: Option<ServerConfig>,
91    auth: Option<Arc<dyn AuthVerifier>>,
92    tool_provider: Option<Arc<dyn ToolProvider>>,
93    serve_widget: bool,
94    widget_token: Option<String>,
95    strict_auth: bool,
96    storage: Option<Arc<dyn StorageAdapter>>,
97    persona: Option<String>,
98    spa_router: Option<axum::Router>,
99    extra_routes: Option<axum::Router>,
100}
101
102impl std::fmt::Debug for LocalServerBuilder {
103    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
104        f.debug_struct("LocalServerBuilder")
105            .field("addr", &self.addr)
106            .field("seed_kb", &self.seed_kb)
107            .field("config", &self.config)
108            // Never print the verifier's secrets — just its mode label.
109            .field("auth", &self.auth.as_ref().map(|a| a.mode()))
110            .field("tool_provider", &self.tool_provider.is_some())
111            .field("serve_widget", &self.serve_widget)
112            .finish()
113    }
114}
115
116impl Default for LocalServerBuilder {
117    fn default() -> Self {
118        Self {
119            addr: DEFAULT_LOCAL_ADDR
120                .parse()
121                .expect("DEFAULT_LOCAL_ADDR is a valid SocketAddr"),
122            seed_kb: false,
123            config: None,
124            auth: None,
125            tool_provider: None,
126            serve_widget: false,
127            widget_token: None,
128            strict_auth: false,
129            storage: None,
130            persona: None,
131            spa_router: None,
132            extra_routes: None,
133        }
134    }
135}
136
137impl LocalServerBuilder {
138    /// Bind on the given address instead of the default `127.0.0.1:8787`.
139    ///
140    /// Use port `0` for an ephemeral port (read the real one back from
141    /// [`LocalServer::addr`] after [`spawn`](Self::spawn)).
142    #[must_use]
143    pub fn addr(mut self, addr: SocketAddr) -> Self {
144        self.addr = addr;
145        self
146    }
147
148    /// Seed the knowledge base with the demo docs on boot (default `false`).
149    /// Handy for an embedded demo / smoke test with grounded answers.
150    #[must_use]
151    pub fn seed_kb(mut self, seed: bool) -> Self {
152        self.seed_kb = seed;
153        self
154    }
155
156    /// Install a custom [`AuthVerifier`] for the local flavor.
157    ///
158    /// Without this, the local flavor runs auth-off ([`NoAuthVerifier`]) — fine
159    /// for pure loopback. Pass a
160    /// [`LocalTokenVerifier`](smooth_operator::auth::LocalTokenVerifier) to gate
161    /// stray local processes with a shared secret (recommended when binding
162    /// beyond loopback, e.g. over a tailnet).
163    #[must_use]
164    pub fn auth(mut self, auth: Arc<dyn AuthVerifier>) -> Self {
165        self.auth = Some(auth);
166        self
167    }
168
169    /// Install a host [`ToolProvider`] so the runner merges its per-turn tools
170    /// into every turn alongside the built-ins (the `#68` injection seam). The
171    /// local flavor uses this to add an OS-sandboxed shell + egress-routed tools
172    /// — the isolation the cloud flavor gets from its container/network sandbox
173    /// instead.
174    #[must_use]
175    pub fn tools(mut self, provider: Arc<dyn ToolProvider>) -> Self {
176        self.tool_provider = Some(provider);
177        self
178    }
179
180    /// Serve the official `@smooai/smooth-operator` widget from this server: a
181    /// host page at `/` and the bundle at `/chat-widget.iife.js`. `token` is
182    /// injected into the page so the widget connects to this server's
183    /// `/ws?token=…` (pair it with a matching [`auth`](Self::auth) verifier);
184    /// pass `None` for a no-auth local server.
185    #[must_use]
186    pub fn serve_widget(mut self, token: Option<String>) -> Self {
187        self.serve_widget = true;
188        self.widget_token = token;
189        self
190    }
191
192    /// Set the **default agent persona** (system prompt) for every turn that has
193    /// no per-org override. A single-tenant host (the local daemon) uses this to
194    /// give the agent its own personality instead of the built-in customer-support
195    /// prompt. Threads to [`AppState::default_persona`](crate::state::AppState::default_persona).
196    /// Unset → the built-in const prompt (unchanged).
197    #[must_use]
198    pub fn persona(mut self, persona: impl Into<String>) -> Self {
199        self.persona = Some(persona.into());
200        self
201    }
202
203    /// Serve a **host-supplied SPA** (e.g. the smooth-web dashboard) at this
204    /// server's own origin, as the router fallback. The operator's explicit routes
205    /// (`/ws`, `/health`, `/admin/*`) still win; everything else (`/`, hashed
206    /// asset paths, SPA client routes) is served by `spa`. Use this INSTEAD of
207    /// [`serve_widget`](Self::serve_widget) when the host wants its own UI at `/`
208    /// — the endpoint is then simply `http://<addr>/` with no `?api`/`?token`
209    /// query string (the host injects the token into the SPA's `index.html`
210    /// itself, so the operator-server stays agnostic to the SPA's auth wiring).
211    #[must_use]
212    pub fn serve_spa(mut self, spa: axum::Router) -> Self {
213        self.spa_router = Some(spa);
214        self
215    }
216
217    /// Merge **host-supplied real routes** into the operator's own router, so they
218    /// sit alongside `/ws`, `/health`, and `/admin/*` as first-class routes (NOT a
219    /// fallback like [`serve_spa`](Self::serve_spa)). The daemon uses this to add
220    /// its own endpoints — e.g. the `@`-mention `GET /search` the web composer
221    /// calls — to the operator origin without the operator-server knowing about
222    /// them.
223    ///
224    /// The supplied routes get the **same permissive CORS as `/admin`** so the
225    /// cross-origin dev SPA (the Vite origin `http://localhost:3100`) can call them
226    /// in the browser. The host is responsible for any auth on these routes; the
227    /// operator merges them verbatim. A route here MUST NOT collide with an
228    /// existing operator path (`/ws`, `/health`, `/admin/*`) — axum panics on a
229    /// duplicate route at merge time.
230    #[must_use]
231    pub fn serve_routes(mut self, routes: axum::Router) -> Self {
232        self.extra_routes = Some(routes);
233        self
234    }
235
236    /// Enable **strict auth**: reject `/ws` connections with a missing/invalid
237    /// token (HTTP 401) instead of degrading to an anonymous connection. Pair
238    /// with [`auth`](Self::auth) — recommended whenever the server is reachable
239    /// beyond loopback (e.g. a tailnet), so a tokenless peer can't drive the
240    /// agent. Off by default.
241    #[must_use]
242    pub fn strict_auth(mut self, strict: bool) -> Self {
243        self.strict_auth = strict;
244        self
245    }
246
247    /// Install a **durable** storage adapter, replacing the default in-memory
248    /// store. This is the seam an always-on, self-hosted deployment (the local
249    /// daemon) uses to persist conversations/sessions/checkpoints across
250    /// restarts without standing up Postgres — the embedder supplies any
251    /// [`StorageAdapter`] (e.g. a local sqlite/dolt one). Unset → in-memory.
252    #[must_use]
253    pub fn storage(mut self, storage: Arc<dyn StorageAdapter>) -> Self {
254        self.storage = Some(storage);
255        self
256    }
257
258    /// Override the full [`ServerConfig`] (e.g. to point at a gateway / model).
259    ///
260    /// The local flavor still **forces** in-memory storage and the caller's bind
261    /// addr regardless of what this config says — the storage/bind fields are
262    /// overwritten — so the "no external services" guarantee always holds. Use
263    /// this to set the gateway URL / key / model / limits for live turns.
264    #[must_use]
265    pub fn config(mut self, config: ServerConfig) -> Self {
266        self.config = Some(config);
267        self
268    }
269
270    /// Build the [`AppState`] for the local flavor: in-memory storage +
271    /// in-memory backplane (the [`build_state`] defaults) with a no-op
272    /// [`NoAuthVerifier`] explicitly installed so `/admin` is reachable in-process
273    /// without configuring `AUTH_MODE`. The gateway config is honored for live
274    /// turns; with no key, `send_message` errors cleanly.
275    fn build(&self) -> AppState {
276        // Start from the caller's config (or the env-independent defaults) and
277        // pin the local-flavor invariants: in-memory storage + the caller's addr.
278        let mut config = self.config.clone().unwrap_or_else(local_config);
279        config.storage = StorageBackend::Memory;
280        config.bind = self.addr.ip().to_string();
281        config.port = self.addr.port();
282        config.seed_kb = self.seed_kb;
283
284        // `build_state` gives in-memory storage + in-memory backplane + permissive
285        // widget auth. Install the caller's verifier, or default to the no-op one
286        // so the admin API is reachable in-process without an `AUTH_MODE=none`
287        // env handshake.
288        let auth = self
289            .auth
290            .clone()
291            .unwrap_or_else(|| Arc::new(NoAuthVerifier::default()) as Arc<dyn AuthVerifier>);
292        let mut state = build_state(config).with_auth(auth);
293        // A durable adapter, when supplied, replaces the in-memory default — the
294        // local flavor stays "no external services" but can now persist.
295        if let Some(storage) = &self.storage {
296            state = state.with_storage(Arc::clone(storage));
297        }
298        if let Some(provider) = &self.tool_provider {
299            state = state.with_tools(Arc::clone(provider));
300        }
301        if self.serve_widget {
302            state = state.with_widget(self.widget_token.clone());
303        }
304        if self.strict_auth {
305            state = state.with_strict_auth(true);
306        }
307        if let Some(persona) = &self.persona {
308            state = state.with_default_persona(persona.clone());
309        }
310        state
311    }
312
313    /// Assemble the full axum [`Router`](axum::Router): the operator's routes
314    /// (`/ws`, `/health`, `/admin/*`, and optionally the widget) plus, when a host
315    /// SPA was installed via [`serve_spa`](Self::serve_spa), that SPA as the
316    /// router fallback (so the explicit operator routes still win). Factored out of
317    /// [`spawn`](Self::spawn) so a test can drive it with `tower::ServiceExt::oneshot`.
318    fn build_app(&self) -> axum::Router {
319        let mut app = router(self.build());
320        // Host-supplied real routes (e.g. the daemon's `/search`) are merged so
321        // they sit alongside the operator's own routes. They carry the same
322        // permissive CORS as `/admin` so the cross-origin dev SPA can call them.
323        if let Some(routes) = self.extra_routes.clone() {
324            app = app.merge(routes.layer(crate::admin::admin_cors()));
325        }
326        // A host SPA (smooth-web) is mounted as the router fallback so the
327        // operator's explicit routes (`/ws`, `/health`, `/admin/*`) still win and
328        // everything else — `/`, hashed assets, SPA client routes — is served by
329        // the SPA at this server's own origin.
330        if let Some(spa) = self.spa_router.clone() {
331            app = app.fallback_service(spa);
332        }
333        app
334    }
335
336    /// Bind and spawn the server in a background task, returning a [`LocalServer`]
337    /// handle carrying the **real** bound address (resolved even for port `0`)
338    /// and a graceful-shutdown switch.
339    ///
340    /// # Errors
341    /// Returns an error if binding the TCP listener fails (e.g. the port is in
342    /// use). Serving errors after a successful bind surface when the handle is
343    /// awaited / shut down.
344    pub async fn spawn(self) -> Result<LocalServer> {
345        let listener = TcpListener::bind(self.addr)
346            .await
347            .with_context(|| format!("binding local smooth-operator server on {}", self.addr))?;
348        let addr = listener.local_addr().context("local addr")?;
349
350        let app = self.build_app();
351        let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
352
353        let join = tokio::spawn(async move {
354            axum::serve(listener, app)
355                .with_graceful_shutdown(async move {
356                    // Resolve on an explicit shutdown signal; if the sender is
357                    // dropped (handle gone) we also stop, so we never leak a task.
358                    let _ = shutdown_rx.await;
359                })
360                .await
361                .context("serving local smooth-operator connections")
362        });
363
364        Ok(LocalServer {
365            addr,
366            shutdown_tx: Some(shutdown_tx),
367            join,
368        })
369    }
370}
371
372/// A running [local-flavor](self) server: its bound address + a graceful
373/// shutdown switch.
374///
375/// Dropping the handle without calling [`shutdown`](Self::shutdown) signals the
376/// server to stop (the shutdown channel closes) and detaches the background
377/// task. Call [`shutdown`](Self::shutdown) to stop **and** await a clean exit.
378#[must_use = "the server stops when the handle is dropped; hold it for the server's lifetime"]
379pub struct LocalServer {
380    addr: SocketAddr,
381    shutdown_tx: Option<tokio::sync::oneshot::Sender<()>>,
382    join: JoinHandle<Result<()>>,
383}
384
385impl LocalServer {
386    /// Start building a local-flavor server. See [`LocalServerBuilder`].
387    pub fn builder() -> LocalServerBuilder {
388        LocalServerBuilder::default()
389    }
390
391    /// The real address the server bound on — already resolved, so this returns
392    /// the concrete ephemeral port when the builder asked for port `0`.
393    #[must_use]
394    pub fn addr(&self) -> SocketAddr {
395        self.addr
396    }
397
398    /// The `ws://<addr>/ws` URL clients connect to.
399    #[must_use]
400    pub fn ws_url(&self) -> String {
401        format!("ws://{}/ws", self.addr)
402    }
403
404    /// Signal graceful shutdown and await the server task's clean exit.
405    ///
406    /// # Errors
407    /// Returns an error if the server task panicked or its serve loop errored.
408    pub async fn shutdown(mut self) -> Result<()> {
409        // Trigger graceful shutdown, then await the serve loop.
410        if let Some(tx) = self.shutdown_tx.take() {
411            let _ = tx.send(());
412        }
413        match (&mut self.join).await {
414            Ok(result) => result,
415            Err(join_err) => Err(anyhow::anyhow!("local server task failed: {join_err}")),
416        }
417    }
418}
419
420impl Drop for LocalServer {
421    fn drop(&mut self) {
422        // Best-effort: if the handle is dropped without `shutdown`, signal the
423        // serve loop to stop so the background task doesn't outlive the handle.
424        if let Some(tx) = self.shutdown_tx.take() {
425            let _ = tx.send(());
426        }
427    }
428}
429
430/// An [`ServerConfig`] for the local flavor: the env-independent defaults
431/// (loopback bind, default gateway URL/model/limits) with **in-memory storage**
432/// pinned. The gateway URL/key are still read from the environment via
433/// [`ServerConfig::from_env`] so a key present in the host's env enables live
434/// turns; absent, `send_message` errors cleanly.
435#[must_use]
436pub fn local_config() -> ServerConfig {
437    let mut config = ServerConfig::from_env();
438    config.storage = StorageBackend::Memory;
439    config
440}
441
442/// Run a [local-flavor](self) server to completion (blocks) on `addr`.
443///
444/// Convenience for the foreground / one-command case: boots a fully in-memory,
445/// auth-off server bound to `addr` and serves until the process is killed. For
446/// an embedded server you can stop programmatically, use
447/// [`LocalServer::builder`] + [`LocalServer::shutdown`] instead.
448///
449/// # Errors
450/// Returns an error if `addr` doesn't parse, the bind fails, or serving fails.
451pub async fn serve_local(addr: &str) -> Result<()> {
452    let addr: SocketAddr = addr
453        .parse()
454        .with_context(|| format!("parsing local bind address '{addr}'"))?;
455    let server = LocalServer::builder().addr(addr).spawn().await?;
456    let local = server.addr();
457    println!("smooth-operator-server (local flavor) listening on ws://{local}/ws");
458    tracing::info!(%local, endpoint = "/ws", "smooth-operator-server (local flavor) listening");
459
460    // Take ownership of the join handle and await it to completion. We can't
461    // call the consuming `shutdown` here (we want to run forever), so await the
462    // task directly via a small accessor.
463    server.run_to_completion().await
464}
465
466impl LocalServer {
467    /// Await the server task to completion (blocks). Used by [`serve_local`] for
468    /// the run-forever foreground case. The handle is consumed; graceful
469    /// shutdown then only happens on process exit / task error.
470    async fn run_to_completion(mut self) -> Result<()> {
471        // Keep the shutdown sender alive (don't fire it) so the server runs until
472        // the task itself ends (process killed / serve error).
473        match (&mut self.join).await {
474            Ok(result) => result,
475            Err(join_err) => Err(anyhow::anyhow!("local server task failed: {join_err}")),
476        }
477        // `self` (and thus `shutdown_tx`) drops here; the loop is already done.
478    }
479}
480
481#[cfg(test)]
482mod tests {
483    use super::*;
484
485    #[tokio::test]
486    async fn spawn_binds_ephemeral_and_reports_real_addr() {
487        // Port 0 → the OS picks a port; the handle must report the real one.
488        let server = LocalServer::builder()
489            .addr("127.0.0.1:0".parse().unwrap())
490            .spawn()
491            .await
492            .expect("spawn local server");
493        let addr = server.addr();
494        assert_ne!(addr.port(), 0, "ephemeral port must be resolved: {addr}");
495        assert!(server.ws_url().starts_with("ws://127.0.0.1:"));
496        server.shutdown().await.expect("clean shutdown");
497    }
498
499    #[tokio::test]
500    async fn build_uses_in_memory_storage_and_no_auth() {
501        let state = LocalServerBuilder::default()
502            .config(ServerConfig {
503                // Even if a caller hands a Postgres config, the local flavor pins
504                // in-memory so the no-external-services guarantee holds.
505                storage: StorageBackend::Postgres,
506                ..local_config()
507            })
508            .build();
509        assert_eq!(state.config.storage, StorageBackend::Memory);
510        // The no-op verifier is installed (admin reachable in-process).
511        assert_eq!(state.auth.mode(), "none");
512    }
513
514    #[test]
515    fn storage_seam_installs_a_durable_adapter() {
516        use smooth_operator_adapter_memory::InMemoryStorageAdapter;
517        // Any StorageAdapter stands in for a durable one; assert the builder
518        // installs the *injected* adapter, not the hardcoded in-memory default.
519        let injected: Arc<dyn StorageAdapter> = Arc::new(InMemoryStorageAdapter::new());
520        let state = LocalServerBuilder::default()
521            .storage(Arc::clone(&injected))
522            .build();
523        assert!(
524            Arc::ptr_eq(&state.storage, &injected),
525            "the injected storage adapter must be installed"
526        );
527        // Default (no override) → a distinct in-memory adapter.
528        let default_state = LocalServerBuilder::default().build();
529        assert!(!Arc::ptr_eq(&default_state.storage, &injected));
530    }
531
532    #[test]
533    fn auth_seam_installs_a_custom_verifier() {
534        use smooth_operator::auth::LocalTokenVerifier;
535        let state = LocalServerBuilder::default()
536            .auth(Arc::new(LocalTokenVerifier::new("s3cret")))
537            .build();
538        assert_eq!(
539            state.auth.mode(),
540            "local-token",
541            "custom verifier overrides the default"
542        );
543    }
544
545    #[test]
546    fn tools_seam_installs_a_provider() {
547        use async_trait::async_trait;
548        use smooth_operator::tool_provider::{ToolProvider, ToolProviderContext};
549        use smooth_operator_core::Tool;
550
551        struct EmptyProvider;
552        #[async_trait]
553        impl ToolProvider for EmptyProvider {
554            async fn tools_for(&self, _ctx: &ToolProviderContext) -> Vec<Arc<dyn Tool>> {
555                Vec::new()
556            }
557        }
558        let state = LocalServerBuilder::default()
559            .tools(Arc::new(EmptyProvider))
560            .build();
561        assert!(state.tool_provider.is_some(), "host ToolProvider installed");
562    }
563
564    #[test]
565    fn serve_widget_opts_into_the_widget_routes_with_token() {
566        let state = LocalServerBuilder::default()
567            .serve_widget(Some("tok-123".into()))
568            .build();
569        assert!(state.serve_widget, "widget routes opted in");
570        assert_eq!(state.widget_token.as_deref(), Some("tok-123"));
571        // Building the router with serve_widget set mounts `/` + the bundle route.
572        let _ = crate::server::router(state);
573    }
574
575    #[test]
576    fn no_widget_by_default() {
577        let state = LocalServerBuilder::default().build();
578        assert!(
579            !state.serve_widget,
580            "widget off by default (K8s/Lambda never serve it)"
581        );
582        assert_eq!(state.widget_token, None);
583    }
584
585    #[test]
586    fn persona_seam_installs_default_persona() {
587        // No persona → no default (built-in const prompt, unchanged behavior).
588        assert_eq!(
589            LocalServerBuilder::default().build().default_persona,
590            None,
591            "no default persona unless set"
592        );
593        // `.persona(..)` threads through to AppState::default_persona.
594        let state = LocalServerBuilder::default()
595            .persona("You are Big Smooth.")
596            .build();
597        assert_eq!(
598            state.default_persona.as_deref(),
599            Some("You are Big Smooth.")
600        );
601    }
602
603    #[tokio::test]
604    async fn serve_spa_mounts_host_router_as_fallback() {
605        use http_body_util::BodyExt;
606        use tower::ServiceExt;
607
608        // A trivial host SPA: any unmatched path returns a sentinel. The
609        // operator's `/health` must still win (an explicit route beats the SPA
610        // fallback).
611        let spa = axum::Router::new().fallback(axum::routing::get(|| async { "SPA-ROOT" }));
612        let app = LocalServerBuilder::default().serve_spa(spa).build_app();
613
614        // `/` (and any non-operator path) routes to the SPA.
615        let res = app
616            .clone()
617            .oneshot(
618                axum::http::Request::builder()
619                    .uri("/")
620                    .body(axum::body::Body::empty())
621                    .unwrap(),
622            )
623            .await
624            .unwrap();
625        assert_eq!(res.status(), axum::http::StatusCode::OK);
626        let body = res.into_body().collect().await.unwrap().to_bytes();
627        assert_eq!(
628            &body[..],
629            b"SPA-ROOT",
630            "the host SPA is served as the fallback"
631        );
632
633        // The operator's explicit `/health` route still wins over the SPA fallback.
634        let res = app
635            .oneshot(
636                axum::http::Request::builder()
637                    .uri("/health")
638                    .body(axum::body::Body::empty())
639                    .unwrap(),
640            )
641            .await
642            .unwrap();
643        assert_eq!(res.status(), axum::http::StatusCode::OK);
644        let body = res.into_body().collect().await.unwrap().to_bytes();
645        assert_eq!(
646            &body[..],
647            b"ok",
648            "explicit operator routes win over the SPA"
649        );
650    }
651
652    #[test]
653    fn no_spa_by_default() {
654        // Without `serve_spa`, an unknown path is a 404 (no fallback service).
655        assert!(
656            LocalServerBuilder::default().spa_router.is_none(),
657            "no SPA mounted unless the host installs one"
658        );
659    }
660
661    #[tokio::test]
662    async fn serve_routes_merges_host_routes_alongside_operator_routes() {
663        use http_body_util::BodyExt;
664        use tower::ServiceExt;
665
666        // A host route that must respond as a real route (not a fallback), while
667        // the operator's own `/health` keeps working.
668        let routes =
669            axum::Router::new().route("/search", axum::routing::get(|| async { "SEARCH-OK" }));
670        let app = LocalServerBuilder::default()
671            .serve_routes(routes)
672            .build_app();
673
674        // The merged host route responds.
675        let res = app
676            .clone()
677            .oneshot(
678                axum::http::Request::builder()
679                    .uri("/search?q=foo")
680                    .body(axum::body::Body::empty())
681                    .unwrap(),
682            )
683            .await
684            .unwrap();
685        assert_eq!(res.status(), axum::http::StatusCode::OK);
686        let body = res.into_body().collect().await.unwrap().to_bytes();
687        assert_eq!(&body[..], b"SEARCH-OK", "merged host route responds");
688
689        // The operator's own `/health` still works alongside the merged routes.
690        let res = app
691            .oneshot(
692                axum::http::Request::builder()
693                    .uri("/health")
694                    .body(axum::body::Body::empty())
695                    .unwrap(),
696            )
697            .await
698            .unwrap();
699        assert_eq!(res.status(), axum::http::StatusCode::OK);
700        let body = res.into_body().collect().await.unwrap().to_bytes();
701        assert_eq!(&body[..], b"ok", "operator routes survive the merge");
702    }
703
704    #[test]
705    fn no_extra_routes_by_default() {
706        assert!(
707            LocalServerBuilder::default().extra_routes.is_none(),
708            "no host routes merged unless the host installs them"
709        );
710    }
711
712    #[test]
713    fn strict_auth_off_by_default_and_opt_in() {
714        assert!(
715            !LocalServerBuilder::default().build().strict_auth,
716            "lenient/anonymous by default"
717        );
718        assert!(
719            LocalServerBuilder::default()
720                .strict_auth(true)
721                .build()
722                .strict_auth,
723            "opt-in threads to AppState"
724        );
725    }
726}