Skip to main content

boatramp_types/
config.rs

1//! Deploy-scoped configuration (the `routing` section of `project.cfg`).
2//!
3//! This is the **immutable, deploy-scoped** config tier: it is authored as the
4//! `routing` section of `project.cfg`, parsed at `sync` time, and folded into
5//! the deployment manifest (`boatramp_core::deploy::Manifest`).
6//! Because it travels inside the manifest it is atomic with the content and
7//! rolls back with it.
8//!
9//! (The mutable, site-scoped tier — domains, TLS, access control — is a separate
10//! `SiteConfig` in the KV store, added alongside the virtualhost/auth work.)
11
12use std::collections::BTreeMap;
13
14use serde::{Deserialize, Serialize};
15
16use crate::error::ConfigError;
17use crate::matcher::Pattern;
18
19/// Deploy-scoped configuration — the `routing` section of `project.cfg`.
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(default, deny_unknown_fields)]
22pub struct DeployConfig {
23    /// Schema version, pinned at [`crate::SCHEMA_VERSION`]. Optional in
24    /// `project.cfg` routing (defaults to 1); always present once folded in.
25    pub version: u32,
26    /// Directory-index candidates, tried in order (default `["index.html"]`).
27    pub index: Vec<String>,
28    /// Map extensionless URLs to `.html` files (`/about` → `/about.html`).
29    pub clean_urls: bool,
30    /// Match the request path **case-insensitively** against redirects, rewrites,
31    /// and static files (`/About.HTML` serves `/about.html`). Off by default
32    /// (paths are case-sensitive); opt-in for case-folding origins.
33    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
34    pub case_insensitive: bool,
35    /// Trailing-slash policy.
36    pub trailing_slash: TrailingSlash,
37    /// Status code → error document (e.g. `404 → /404.html`).
38    pub error_documents: BTreeMap<u16, String>,
39    /// Redirect rules (first match wins).
40    pub redirects: Vec<Redirect>,
41    /// Rewrite rules (internal rewrite or proxy; first match wins).
42    pub rewrites: Vec<Rewrite>,
43    /// Response-header rules (all matching rules apply, in order).
44    pub headers: Vec<HeaderRule>,
45    /// Cache-Control defaults.
46    pub cache: CacheConfig,
47    /// Extension → MIME overrides (e.g. `.webmanifest`).
48    pub mime_overrides: BTreeMap<String, String>,
49    /// Allowed upstream hosts for proxy rewrites (exact host or `.suffix`
50    /// match). When empty, proxying to any *public* host is allowed; private,
51    /// loopback, link-local, and similar internal addresses are always blocked
52    /// (SSRF guard), regardless of this list.
53    pub proxy_allow: Vec<String>,
54    /// WebAssembly request handlers (deploy-scoped).
55    /// Matched before static lookup, after redirects.
56    #[serde(default, skip_serializing_if = "Vec::is_empty")]
57    pub handlers: Vec<HandlerConfig>,
58    /// Message-consumer components, invoked per message on a topic.
59    #[serde(default, skip_serializing_if = "Vec::is_empty")]
60    pub consumers: Vec<ConsumerConfig>,
61    /// Scheduled handler invocations (cron).
62    #[serde(default, skip_serializing_if = "Vec::is_empty")]
63    pub crons: Vec<CronConfig>,
64    /// Host-level SSE endpoints fanning out messaging topics.
65    #[serde(default, skip_serializing_if = "Vec::is_empty")]
66    pub streams: Vec<StreamConfig>,
67    /// Duplex/resumable session routes (`PLAN-session-primitive`): a guest `session-handler` the
68    /// host re-enters per inbound frame, with host-owned ordering/resume/lifetime.
69    #[serde(default, skip_serializing_if = "Vec::is_empty")]
70    pub sessions: Vec<SessionConfig>,
71}
72
73impl Default for DeployConfig {
74    fn default() -> Self {
75        Self {
76            version: crate::SCHEMA_VERSION,
77            index: vec!["index.html".to_string()],
78            clean_urls: false,
79            case_insensitive: false,
80            trailing_slash: TrailingSlash::default(),
81            error_documents: BTreeMap::new(),
82            redirects: Vec::new(),
83            rewrites: Vec::new(),
84            headers: Vec::new(),
85            cache: CacheConfig::default(),
86            mime_overrides: BTreeMap::new(),
87            proxy_allow: Vec::new(),
88            handlers: Vec::new(),
89            consumers: Vec::new(),
90            crons: Vec::new(),
91            streams: Vec::new(),
92            sessions: Vec::new(),
93        }
94    }
95}
96
97impl DeployConfig {
98    /// Parse a deploy-scoped `routing` document (RON). `implicit_some` is enabled
99    /// so optional fields can be written as bare values (not `Some("...")`).
100    pub fn from_ron(text: &str) -> Result<Self, ConfigError> {
101        let options = ron::Options::default()
102            .with_default_extension(ron::extensions::Extensions::IMPLICIT_SOME);
103        let config: Self = options
104            .from_str(text)
105            .map_err(|err| ConfigError::parse(err.to_string()))?;
106        config.compile_check()?;
107        Ok(config)
108    }
109
110    /// Whether `host` is permitted as a proxy-rewrite upstream by the
111    /// `proxy_allow` list. An empty list permits any host (the separate
112    /// public-IP SSRF guard still applies); otherwise the host must equal an
113    /// entry or be a subdomain of a `.`-prefixed suffix entry.
114    pub fn proxy_host_allowed(&self, host: &str) -> bool {
115        if self.proxy_allow.is_empty() {
116            return true;
117        }
118        let host = host.trim_end_matches('.').to_ascii_lowercase();
119        self.proxy_allow.iter().any(|entry| {
120            let entry = entry.trim().to_ascii_lowercase();
121            match entry.strip_prefix('.') {
122                Some(suffix) => host == suffix || host.ends_with(&format!(".{suffix}")),
123                None => host == entry,
124            }
125        })
126    }
127
128    /// Verify every route/header pattern compiles. Used by `from_ron` and the
129    /// `validate` subcommand so bad patterns fail fast at deploy time.
130    pub fn compile_check(&self) -> Result<(), ConfigError> {
131        for redirect in &self.redirects {
132            Pattern::compile(&redirect.from)?;
133            if let Some(when) = &redirect.when {
134                crate::predicate::Predicate::compile(when)?;
135            }
136            if crate::predicate::Template::is_template(&redirect.to) {
137                crate::predicate::Template::compile(&redirect.to)?;
138            }
139        }
140        for rewrite in &self.rewrites {
141            Pattern::compile(&rewrite.from)?;
142            if let Some(when) = &rewrite.when {
143                crate::predicate::Predicate::compile(when)?;
144            }
145            if crate::predicate::Template::is_template(&rewrite.to) {
146                crate::predicate::Template::compile(&rewrite.to)?;
147            }
148        }
149        for header in &self.headers {
150            Pattern::compile(&header.matches)?;
151        }
152        self.check_handlers()?;
153        Ok(())
154    }
155
156    /// Offline validation of the handler/consumer/cron/stream config: route
157    /// patterns compile, HTTP methods and requested imports are recognized,
158    /// cron schedules parse, and every cron route is served by some declared
159    /// handler. (Component *binary* validation happens at `sync`, where the
160    /// `.wasm` bytes are available.)
161    fn check_handlers(&self) -> Result<(), ConfigError> {
162        let handler_patterns: Vec<Pattern> = self
163            .handlers
164            .iter()
165            .map(|h| Pattern::compile(&h.route))
166            .collect::<Result<_, _>>()?;
167
168        for handler in &self.handlers {
169            if handler.component.is_empty() {
170                return Err(ConfigError::parse(format!(
171                    "handler {} has an empty component path",
172                    handler.route
173                )));
174            }
175            for method in &handler.methods {
176                check_http_method(method)?;
177            }
178            for import in &handler.imports {
179                check_import(import)?;
180            }
181            // `env` is for static, non-secret strings; a secret belongs in
182            // `[handlers].secrets` as a *reference* to a host env var, so it
183            // never lands in the (content-addressed, stored) manifest.
184            // Best-effort heuristic — catches accidents.
185            for (key, value) in &handler.env {
186                if looks_like_secret(value) {
187                    return Err(ConfigError::parse(format!(
188                        "handler {} env var {key:?} looks like a secret; move it to \
189                         [handlers].secrets as a reference to a host env var rather than \
190                         inlining it in `env` (which is stored in the manifest)",
191                        handler.route
192                    )));
193                }
194            }
195        }
196        for consumer in &self.consumers {
197            if consumer.topic.is_empty() || consumer.component.is_empty() {
198                return Err(ConfigError::parse(
199                    "consumer needs a non-empty topic and component".to_string(),
200                ));
201            }
202            for import in &consumer.imports {
203                check_import(import)?;
204            }
205        }
206        for cron in &self.crons {
207            check_cron_schedule(&cron.schedule)?;
208            if !handler_patterns.iter().any(|p| p.is_match(&cron.route)) {
209                return Err(ConfigError::parse(format!(
210                    "cron route {} is not served by any declared handler",
211                    cron.route
212                )));
213            }
214        }
215        for stream in &self.streams {
216            Pattern::compile(&stream.route)?;
217            if stream.topics.is_empty() {
218                return Err(ConfigError::parse(format!(
219                    "stream {} subscribes to no topics",
220                    stream.route
221                )));
222            }
223        }
224        for session in &self.sessions {
225            Pattern::compile(&session.route)?;
226            if session.component.is_empty() {
227                return Err(ConfigError::parse(format!(
228                    "session {} has an empty component path",
229                    session.route
230                )));
231            }
232            for import in &session.imports {
233                check_import(import)?;
234            }
235        }
236        Ok(())
237    }
238}
239
240/// The standard interface vocabulary a handler may request.
241/// `sql` is the one generic non-`wasi:` interface.
242const KNOWN_IMPORTS: &[&str] = &[
243    "sql",
244    "invoke",
245    "graphql",
246    "email",
247    "wasi:http",
248    "wasi:io",
249    "wasi:keyvalue",
250    "wasi:blobstore",
251    "wasi:messaging",
252    "wasi:clocks",
253    "wasi:random",
254    "wasi:logging",
255];
256
257/// The import names a deploy may declare in a handler's `imports` (baseline WASI
258/// interfaces + boatramp capability tokens). `sql:<name>`/`sql:*` are additionally
259/// accepted for named databases (see [`is_named_sql_import`]).
260pub fn known_imports() -> &'static [&'static str] {
261    KNOWN_IMPORTS
262}
263
264/// Best-effort heuristic: does `value` look like a credential that should be a
265/// `secrets` reference rather than a static `env` string?
266/// Catches the common accidents — it is a guard, not a guarantee.
267fn looks_like_secret(value: &str) -> bool {
268    let v = value.trim();
269    // A PEM private-key block.
270    if v.contains("-----BEGIN") && v.contains("PRIVATE KEY") {
271        return true;
272    }
273    // Well-known credential prefixes (cloud keys, VCS/chat/LLM tokens, …).
274    const PREFIXES: &[&str] = &[
275        "AKIA",
276        "ASIA",
277        "ghp_",
278        "gho_",
279        "ghu_",
280        "ghs_",
281        "github_pat_",
282        "xoxb-",
283        "xoxp-",
284        "xoxa-",
285        "glpat-",
286        "AIza",
287        "AccountKey=",
288    ];
289    if PREFIXES.iter().any(|p| v.contains(p)) {
290        return true;
291    }
292    let has_digit = v.bytes().any(|b| b.is_ascii_digit());
293    // A long pure-hex blob (API key / hash-shaped secret).
294    if v.len() >= 40 && has_digit && v.bytes().all(|b| b.is_ascii_hexdigit()) {
295        return true;
296    }
297    // A long, mixed-case, token-charset, high-entropy string (base64-ish key).
298    let charset_ok = v
299        .bytes()
300        .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'/' | b'=' | b'-' | b'_'));
301    let mixed_case =
302        v.bytes().any(|b| b.is_ascii_uppercase()) && v.bytes().any(|b| b.is_ascii_lowercase());
303    v.len() >= 32 && charset_ok && has_digit && mixed_case && shannon_entropy_bits(v) >= 3.5
304}
305
306/// Shannon entropy of `s` in bits per character (0 for empty).
307fn shannon_entropy_bits(s: &str) -> f64 {
308    if s.is_empty() {
309        return 0.0;
310    }
311    let mut counts = [0u32; 256];
312    for b in s.bytes() {
313        counts[b as usize] += 1;
314    }
315    let len = s.len() as f64;
316    counts
317        .iter()
318        .filter(|&&c| c > 0)
319        .map(|&c| {
320            let p = c as f64 / len;
321            -p * p.log2()
322        })
323        .sum()
324}
325
326/// Whether `import` is a **named SQL binding** grant: `sql:<name>` (a specific database) or
327/// `sql:*` (every named database the site exposes). The bare `sql` (in `KNOWN_IMPORTS`) remains
328/// the default database. A name is a conservative identifier so it can't smuggle a path or
329/// injection through `sql.open(name)`.
330fn is_named_sql_import(import: &str) -> bool {
331    let Some(name) = import.strip_prefix("sql:") else {
332        return false;
333    };
334    name == "*"
335        || (!name.is_empty()
336            && name
337                .chars()
338                .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-'))
339}
340
341/// Whether `import` is a **guest project-admin surface** grant: `admin:<surface>` for one of
342/// the config surfaces the `boatramp:handlers/admin` capability exposes. A bare `admin` is
343/// deliberately NOT a grant — a guest must name the specific surface (deny-by-default,
344/// least-privilege), and there is no `admin:*`. The surface set is closed; a new surface is an
345/// explicit addition here + in the host.
346pub fn is_named_admin_import(import: &str) -> bool {
347    matches!(
348        import,
349        "admin:domains" | "admin:email" | "admin:site" | "admin:secrets"
350    )
351}
352
353fn check_import(import: &str) -> Result<(), ConfigError> {
354    // `session` is accepted here (client-side cfg vocabulary) but is intentionally NOT in
355    // `KNOWN_IMPORTS`: a session route grants the session binding intrinsically, and the host
356    // advertises `session` as an Experimental capability only when the `session` feature is
357    // compiled — so the ABI gate (a component's `requires = ["session"]` vs the host's advertised
358    // set) enforces host support at activation, while this keeps `imports: ["session"]` from being
359    // rejected offline regardless of which host build the deploy targets.
360    if KNOWN_IMPORTS.contains(&import)
361        || import == "session"
362        || is_named_sql_import(import)
363        || is_named_admin_import(import)
364    {
365        Ok(())
366    } else {
367        Err(ConfigError::parse(format!(
368            "unknown handler import {import:?}; allowed: {}, `session`, a named SQL binding `sql:<name>` / `sql:*`, or an admin surface `admin:{{domains,email,site,secrets}}`",
369            KNOWN_IMPORTS.join(", ")
370        )))
371    }
372}
373
374fn check_http_method(method: &str) -> Result<(), ConfigError> {
375    const METHODS: &[&str] = &["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"];
376    if METHODS.contains(&method) {
377        Ok(())
378    } else {
379        Err(ConfigError::parse(format!(
380            "unknown HTTP method {method:?}"
381        )))
382    }
383}
384
385/// Validate a standard 5-field cron schedule (`minute hour dom month dow`).
386/// Each field is `*`, `*/step`, a number, an `a-b` range, an `a-b/step`, or a
387/// comma list of those, within the field's numeric bounds.
388fn check_cron_schedule(schedule: &str) -> Result<(), ConfigError> {
389    // Validation = parsing the schedule (the same parser the scheduler uses to
390    // evaluate it — one grammar, no drift).
391    crate::cron::CronSchedule::parse(schedule)
392        .map(|_| ())
393        .map_err(|err| ConfigError::parse(format!("cron schedule {schedule:?}: {err}")))
394}
395
396/// Trailing-slash handling for request paths.
397#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
398pub enum TrailingSlash {
399    /// Leave the path as-is.
400    #[default]
401    Preserve,
402    /// Redirect to add a trailing slash.
403    Always,
404    /// Redirect to strip a trailing slash.
405    Never,
406}
407
408/// A redirect rule.
409#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
410#[serde(deny_unknown_fields)]
411pub struct Redirect {
412    /// Source pattern (see [`crate::matcher`]).
413    pub from: String,
414    /// Destination, with `:name`/`:splat` substitution.
415    pub to: String,
416    /// HTTP status (default 308 — permanent, method-preserving).
417    #[serde(default = "default_redirect_status")]
418    pub status: u16,
419    /// Optional server-side condition (a [`crate::predicate`] expression over the
420    /// request — `Accept-Language`, cookies, headers, `file_exists(...)`, …). When
421    /// set, the rule fires only if it evaluates true. Compiled + type-checked at
422    /// `validate`/`sync`.
423    #[serde(default, skip_serializing_if = "Option::is_none")]
424    pub when: Option<String>,
425}
426
427fn default_redirect_status() -> u16 {
428    308
429}
430
431/// A rewrite rule: serve a different path (internal) or proxy (absolute URL).
432#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
433#[serde(deny_unknown_fields)]
434pub struct Rewrite {
435    /// Source pattern.
436    pub from: String,
437    /// Internal path or absolute proxy URL, with `:name`/`:splat` substitution.
438    pub to: String,
439    /// Status to serve for an internal rewrite (default 200).
440    #[serde(default = "default_rewrite_status")]
441    pub status: u16,
442    /// Optional server-side condition — see [`Redirect::when`].
443    #[serde(default, skip_serializing_if = "Option::is_none")]
444    pub when: Option<String>,
445}
446
447fn default_rewrite_status() -> u16 {
448    200
449}
450
451/// A response-header rule applied to matching paths.
452#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
453#[serde(deny_unknown_fields)]
454pub struct HeaderRule {
455    /// Path pattern to match (named `matches` because `for` is a Rust keyword).
456    pub matches: String,
457    /// Headers to set.
458    #[serde(default)]
459    pub set: BTreeMap<String, String>,
460    /// Header names to remove.
461    #[serde(default)]
462    pub unset: Vec<String>,
463}
464
465/// Cache-Control defaults.
466#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
467#[serde(default, deny_unknown_fields)]
468pub struct CacheConfig {
469    /// Default `Cache-Control` for responses not covered by a header rule.
470    pub default: Option<String>,
471}
472
473/// A WebAssembly request handler bound to a route (deploy-scoped).
474#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
475#[serde(deny_unknown_fields)]
476pub struct HandlerConfig {
477    /// Route pattern (matcher syntax).
478    pub route: String,
479    /// HTTP methods this handler answers (empty = all).
480    #[serde(default)]
481    pub methods: Vec<String>,
482    /// Path to the component `.wasm` within the deployment.
483    pub component: String,
484    /// Requested capabilities (interface names; see `KNOWN_IMPORTS`).
485    #[serde(default)]
486    pub imports: Vec<String>,
487    /// A **streaming** handler: the guest writes its response body incrementally
488    /// (SSE, chunked, agent token streaming) via a `#[handler(stream)]` body. The
489    /// host serves it on the isolated **streaming lane** (its own concurrency
490    /// budget + a much larger wall-clock) so a long-lived stream never holds a
491    /// fast-request slot. Should match the guest's declared shape — the activation
492    /// pre-check *warns* when a `#[handler(stream)]` guest is deployed without it
493    /// (it would otherwise run on the sync lane and be cut at the sync timeout).
494    /// Default `false` (a buffered handler).
495    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
496    pub streaming: bool,
497    /// Optional resource limits (capped by site config at activation).
498    #[serde(default, skip_serializing_if = "Option::is_none")]
499    pub limits: Option<HandlerLimits>,
500    /// Static environment variables (never secrets).
501    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
502    pub env: BTreeMap<String, String>,
503    /// Function-to-function invoke allowlist (FI): the target names this handler may
504    /// call through the `invoke` capability (same contract as
505    /// [`FunctionConfig::invoke_targets`](crate::function::FunctionConfig)). Each entry
506    /// may use `*` wildcards (`*` = any function, `img-*` = a family, `resize` = one
507    /// literal). Deny by default: empty ⇒ the handler cannot invoke anything, even if it
508    /// imports `invoke`. Only consulted when `imports` contains `invoke` and the site's
509    /// `allow_imports` permits it.
510    #[serde(default, skip_serializing_if = "Vec::is_empty")]
511    pub invoke_targets: Vec<String>,
512}
513
514/// Per-handler resource limits.
515#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
516#[serde(deny_unknown_fields)]
517pub struct HandlerLimits {
518    /// Max linear memory, MiB.
519    #[serde(default, skip_serializing_if = "Option::is_none")]
520    pub memory_mb: Option<u32>,
521    /// Wall-clock timeout, milliseconds.
522    #[serde(default, skip_serializing_if = "Option::is_none")]
523    pub timeout_ms: Option<u32>,
524    /// CPU budget in wasmtime **fuel** units (instruction-count proxy); the
525    /// guest traps when it runs out. A deterministic CPU bound on top of the
526    /// wall-clock timeout. Omitted = unmetered.
527    #[serde(default, skip_serializing_if = "Option::is_none")]
528    pub fuel: Option<u64>,
529}
530
531/// Where a **new** consumer group starts consuming a topic (its initial cursor).
532/// A group with no `group` name is the default work-queue and ignores this.
533#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
534#[serde(rename_all = "snake_case")]
535pub enum StartPosition {
536    /// Only events published from the group's first subscription onward (the
537    /// conventional default; prior history is not replayed).
538    #[default]
539    Latest,
540    /// Every event still retained on the topic, oldest-first (replay the backlog).
541    Earliest,
542}
543
544/// A message-consumer component, invoked per message on a topic.
545#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
546#[serde(deny_unknown_fields)]
547pub struct ConsumerConfig {
548    /// Topic to subscribe to (namespaced like all topics).
549    pub topic: String,
550    /// Path to the component `.wasm` within the deployment.
551    pub component: String,
552    /// Requested capabilities.
553    #[serde(default)]
554    pub imports: Vec<String>,
555    /// Consumer **group**: empty (default) = the competing-consumer work-queue
556    /// (one of the site's consumers processes each message); a non-empty name = a
557    /// durable fan-out subscriber that receives *every* message on the topic
558    /// independently of other groups. Two consumers with different groups on one
559    /// topic each get every message.
560    #[serde(default, skip_serializing_if = "String::is_empty")]
561    pub group: String,
562    /// Where a non-empty `group` starts on first subscription (`latest` |
563    /// `earliest`). Ignored for the default work-queue.
564    #[serde(default, skip_serializing_if = "crate::config::is_default_start")]
565    pub start: StartPosition,
566}
567
568/// serde `skip_serializing_if` helper: a `Latest` start is the default and elided.
569pub fn is_default_start(s: &StartPosition) -> bool {
570    *s == StartPosition::default()
571}
572
573/// A scheduled invocation of a declared handler route.
574#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
575#[serde(deny_unknown_fields)]
576pub struct CronConfig {
577    /// Standard 5-field cron schedule.
578    pub schedule: String,
579    /// Handler route to invoke (must be served by a declared handler).
580    pub route: String,
581    /// Overlap policy when a previous run is still in flight.
582    #[serde(default)]
583    pub overlap: Overlap,
584}
585
586/// Cron overlap policy.
587#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
588pub enum Overlap {
589    /// Skip the tick if the previous invocation is still running (default).
590    #[default]
591    Skip,
592    /// Allow concurrent invocations.
593    Allow,
594}
595
596/// A host-level SSE (or WebSocket) endpoint fanning out messaging topics.
597#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
598#[serde(deny_unknown_fields)]
599pub struct StreamConfig {
600    /// Route the SSE (or WebSocket) endpoint is served at.
601    pub route: String,
602    /// Topics whose messages are broadcast to connected clients (server→client).
603    pub topics: Vec<String>,
604    /// Serve this route as a **WebSocket** instead of SSE:
605    /// the same `topics` fan out server→client, and — bidirectionally — messages
606    /// the client sends are published to [`publish_topic`](Self::publish_topic).
607    /// Off by default (SSE).
608    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
609    pub websocket: bool,
610    /// For a `websocket` stream, the (scope-relative) topic that client→server
611    /// messages are published to. `None` = the socket is receive-only (client
612    /// sends are dropped).
613    #[serde(default, skip_serializing_if = "Option::is_none")]
614    pub publish_topic: Option<String>,
615}
616
617/// A **duplex, resumable session** route (`PLAN-session-primitive`): a long-lived,
618/// client-addressable, bidirectional channel served by a guest component's `session-handler`
619/// export, which the host re-enters per inbound frame (mechanism B). The host opens the session on
620/// `route` (SSE-out + POST-in), binds the verified principal, buffers/orders outbound frames with
621/// resume-from-cursor, and persists a resumable checkpoint. Frames are opaque bytes. Unlike a
622/// [`StreamConfig`] (host-only pub/sub fan-out), a session runs guest code and carries a backchannel.
623#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
624#[serde(default, deny_unknown_fields)]
625pub struct SessionConfig {
626    /// Route the session is opened at (the client `GET`s it for the SSE stream and `POST`s inbound
627    /// frames to it). Matcher syntax, like a handler route.
628    pub route: String,
629    /// Path to the session component `.wasm` within the deployment (exports `session-handler`).
630    pub component: String,
631    /// Requested capabilities (interface names; see `KNOWN_IMPORTS`). A session handler declares
632    /// `session` plus whatever `sql`/`orm`/`invoke`/… it uses per frame.
633    #[serde(default, skip_serializing_if = "Vec::is_empty")]
634    pub imports: Vec<String>,
635    /// Optional resource limits (capped by site config at activation).
636    #[serde(default, skip_serializing_if = "Option::is_none")]
637    pub limits: Option<HandlerLimits>,
638    /// Static environment variables (never secrets).
639    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
640    pub env: BTreeMap<String, String>,
641    /// Function-to-function invoke allowlist (same contract as a handler's `invoke_targets`).
642    #[serde(default, skip_serializing_if = "Vec::is_empty")]
643    pub invoke_targets: Vec<String>,
644    /// In-site tenancy decision for this session's `sql`/`orm` (Dimension 0). Resolved once at
645    /// **open** from the verified source and carried across every re-entry, so a frame-triggered
646    /// query is host-scoped identically to a normal handler. Absent ⇒ plain (project = database).
647    #[serde(default, skip_serializing_if = "Option::is_none")]
648    pub tenancy: Option<crate::tenancy::Tenancy>,
649    /// JWKS/issuer verifying the app bearer for a `token`-sourced tenant (same as a function's).
650    #[serde(default, skip_serializing_if = "Option::is_none")]
651    pub token_claims: Option<HandlerGraphqlTokenClaims>,
652}
653
654/// Site-scoped, mutable configuration stored in the KV (not in the manifest).
655///
656/// Carries domains (virtualhost routing) and visitor access control; TLS,
657/// previews, and retention land with their respective workstreams.
658#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
659#[serde(default, deny_unknown_fields)]
660pub struct SiteConfig {
661    /// Schema version, pinned at [`crate::SCHEMA_VERSION`].
662    pub version: u32,
663    /// Hostnames this site answers to.
664    pub domains: DomainConfig,
665    /// Transport security: HTTPS redirect + HSTS (site tier).
666    #[serde(default)]
667    pub security: SecurityConfig,
668    /// Visitor access control (basic auth, IP rules, rate limiting).
669    #[serde(default)]
670    pub access: crate::access::AccessConfig,
671    /// WebAssembly handler caps + import allowlist (site-scoped).
672    /// `None` = handlers disabled for the site.
673    #[serde(default, skip_serializing_if = "Option::is_none")]
674    pub handlers: Option<HandlersSiteConfig>,
675    /// On-the-fly response compression. Off by default;
676    /// complements the precompressed-variant path for dynamic/unvaried responses.
677    #[serde(default, skip_serializing_if = "CompressionConfig::is_default")]
678    pub compression: CompressionConfig,
679    /// Reverse-proxy gateway for publishing private services.
680    /// `None` = no gateway routes. Declaring an upstream here is what authorizes
681    /// reaching a private address (the SSRF guard stays public-only otherwise).
682    #[serde(default, skip_serializing_if = "Option::is_none")]
683    pub gateway: Option<crate::gateway::GatewayConfig>,
684}
685
686impl Default for SiteConfig {
687    fn default() -> Self {
688        Self {
689            version: crate::SCHEMA_VERSION,
690            domains: DomainConfig::default(),
691            security: SecurityConfig::default(),
692            access: crate::access::AccessConfig::default(),
693            handlers: None,
694            compression: CompressionConfig::default(),
695            gateway: None,
696        }
697    }
698}
699
700/// On-the-fly compression policy. Opt-in: compresses a
701/// response *only* when it has no precompressed variant / existing
702/// `Content-Encoding`, its type is compressible, and (when known) its length is
703/// at least `min_size`. Credentialed responses are skipped (BREACH safety).
704#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
705#[serde(default, deny_unknown_fields)]
706pub struct CompressionConfig {
707    /// Master toggle (default off).
708    pub enabled: bool,
709    /// Don't compress a response whose `Content-Length` is below this (bytes).
710    /// Streaming responses with no declared length are always eligible.
711    pub min_size: u64,
712}
713
714impl Default for CompressionConfig {
715    fn default() -> Self {
716        Self {
717            enabled: false,
718            min_size: 1024,
719        }
720    }
721}
722
723impl CompressionConfig {
724    fn is_default(&self) -> bool {
725        *self == Self::default()
726    }
727}
728
729/// Site-scoped handler policy: the capability allowlist and resource caps that
730/// a deployment's requested handler config is intersected against at
731/// activation (deny by default).
732#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
733#[serde(default, deny_unknown_fields)]
734pub struct HandlersSiteConfig {
735    /// Whether handlers run for this site at all.
736    pub enabled: bool,
737    /// Interfaces handlers on this site may import (subset of `KNOWN_IMPORTS`).
738    pub allow_imports: Vec<String>,
739    /// Cap on per-handler memory (MiB).
740    #[serde(skip_serializing_if = "Option::is_none")]
741    pub max_memory_mb: Option<u32>,
742    /// Cap on per-handler timeout (ms).
743    #[serde(skip_serializing_if = "Option::is_none")]
744    pub max_timeout_ms: Option<u32>,
745    /// Cap on concurrent invocations for the site.
746    #[serde(skip_serializing_if = "Option::is_none")]
747    pub max_concurrency: Option<u32>,
748    /// Cap on per-handler CPU **fuel** (instruction-count proxy). A per-handler
749    /// `fuel` may only lower this, never raise it.
750    #[serde(skip_serializing_if = "Option::is_none")]
751    pub max_fuel: Option<u64>,
752    /// Env-var name → secret reference, injected at instantiation (the value is
753    /// a backend reference, resolved server-side — never a literal secret here).
754    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
755    pub secrets: BTreeMap<String, String>,
756    /// Named aliases (besides the live/current deployment) whose deployments
757    /// also run **background work** — consumers and crons. The
758    /// current deployment always runs background work; previews never do. Empty
759    /// by default, so only the current deployment is background-active. Each
760    /// listed alias gets its own topic namespace (`{site}/{alias}/…`), isolated
761    /// from the live one (e.g. opt `staging` in).
762    #[serde(default, skip_serializing_if = "Vec::is_empty")]
763    pub background_aliases: Vec<String>,
764    /// Cap on concurrent SSE stream connections for the site.
765    #[serde(skip_serializing_if = "Option::is_none")]
766    pub max_stream_connections: Option<u32>,
767    /// Cap on captured guest log lines per second for the site, so a noisy guest
768    /// can't flood the log sink. Lines over the cap are
769    /// dropped (counted). `None` = the server default.
770    #[serde(skip_serializing_if = "Option::is_none")]
771    pub max_log_rate: Option<u32>,
772    /// Opt **out** of capturing the site's guest `stdout`/`stderr` + `wasi:logging`. Capture is
773    /// **on by default** (served via the logs endpoint + SSE tail, mirrored to `serve.log`); set
774    /// this `true` to disable it — e.g. when a guest's output may carry secrets/PII. When
775    /// disabled, the guest's stdio is discarded and never reaches the store, the logs API, or
776    /// `serve.log`. (Inverted so the default — capture on — matches `Default`.)
777    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
778    pub disable_log_capture: bool,
779    /// Edge response cache. Off unless present + `enabled`. When on, a
780    /// cacheable `GET`/`HEAD` response the handler opts in via
781    /// `Cache-Control: max-age=…` is stored and served for later identical
782    /// requests **without re-instantiating the handler**. Never caches a private
783    /// response (`no-store`/`private`, `Set-Cookie`, `Vary: *`, or an
784    /// `Authorization` request without `public`/`s-maxage`).
785    #[serde(default, skip_serializing_if = "Option::is_none")]
786    pub cache: Option<HandlerCacheConfig>,
787    /// GraphQL edge query-guard. Off unless present + `enabled`. When on, an
788    /// incoming GraphQL operation is parsed at the edge and rejected **before the
789    /// handler runs** if it exceeds the depth or complexity limit, or (unless
790    /// allowed) is a schema-introspection query. Defense-in-depth over the fuel cap.
791    #[serde(default, skip_serializing_if = "Option::is_none")]
792    pub graphql: Option<HandlerGraphqlConfig>,
793    /// Browser cookie session auth. Off unless present. When set, a request with the named
794    /// cookie but **no** `Authorization` header is authenticated from the cookie value: it
795    /// becomes the app bearer token everywhere the header bearer already flows (managed
796    /// handlers, the GraphQL edge, the data connector, invoked functions, `graphql::run`). The
797    /// `Authorization` header always wins, so API clients are unaffected. boatramp **only reads**
798    /// the cookie — the app's auth handler issues + refreshes it. Set it `HttpOnly; Secure;
799    /// SameSite=Lax` (Lax is a CSRF requirement — the browser half of the defense) with a
800    /// `__Host-` name prefix; and keep cookie-auth `GET`/`HEAD` handlers side-effect-free (a
801    /// same-origin top-level navigation passes the CSRF gate). A cookie-authenticated request is
802    /// CSRF-checked: same-origin always passes, and `allowed_origins` adds any cross-origins.
803    #[serde(default, skip_serializing_if = "Option::is_none")]
804    pub cookie_auth: Option<CookieAuthConfig>,
805    /// Site-level in-site tenancy decision (Dimension 0) — the **ceiling** for this site's
806    /// handlers' `sql`/`orm` access. A per-function [`crate::function::FunctionConfig::tenancy`]
807    /// may narrow within it but not widen it (e.g. a site pinned to `read: own` can't be raised
808    /// to `all` by a function). Absent ⇒ *undeclared* (refused under `multi-tenant`, treated as
809    /// `Disabled` under single-tenant/dev).
810    #[serde(default, skip_serializing_if = "Option::is_none")]
811    pub tenancy: Option<crate::tenancy::Tenancy>,
812}
813
814/// Browser cookie session auth for a site (see [`HandlersSiteConfig::cookie_auth`]). boatramp
815/// only **reads** the cookie; the app sets it. The cookie value is the app bearer token, opaque
816/// to boatramp (the app's own `Authorizer` / OIDC config verifies it, exactly as for a header
817/// bearer).
818#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
819#[serde(default, deny_unknown_fields)]
820pub struct CookieAuthConfig {
821    /// The cookie whose value is used as the bearer when no `Authorization` header is present.
822    pub cookie_name: String,
823    /// The **additional cross-origin** CSRF allowlist. A **same-origin** request (its `Origin`
824    /// authority equals the request's own `Host`) is always allowed — a page calling its own
825    /// origin, the normal SPA case, is definitionally CSRF-safe. This list adds the *other*
826    /// origins a browser app served from a **different** origin than this API may come from; each
827    /// entry is a scheme+host[+port] origin, e.g. `https://app.example.com`. A cross-origin
828    /// request whose `Origin` (or, absent that, `Referer`) is not same-origin and **not** listed
829    /// is rejected. **Empty ⇒ same-origin only** — the common case needs no configuration.
830    #[serde(default, skip_serializing_if = "Vec::is_empty")]
831    pub allowed_origins: Vec<String>,
832}
833
834/// Per-site GraphQL edge query-guard tuning (see [`HandlersSiteConfig::graphql`]).
835#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
836#[serde(default, deny_unknown_fields)]
837pub struct HandlerGraphqlConfig {
838    /// Master switch. `false` (the default) ⇒ the guard is inert even if present.
839    pub enabled: bool,
840    /// Deepest allowed selection-set nesting (fragments expanded). `None` ⇒ the
841    /// server default.
842    #[serde(skip_serializing_if = "Option::is_none")]
843    pub max_depth: Option<u32>,
844    /// Largest allowed total field count (a schema-free complexity proxy). `None` ⇒
845    /// the server default.
846    #[serde(skip_serializing_if = "Option::is_none")]
847    pub max_complexity: Option<u32>,
848    /// Whether a schema-introspection query is allowed. `None` ⇒ the posture default
849    /// (**off** under the multi-tenant posture, on for single-tenant).
850    #[serde(skip_serializing_if = "Option::is_none")]
851    pub introspection: Option<bool>,
852    /// Automatic Persisted Queries: clients may send a query hash
853    /// (`extensions.persistedQuery.sha256Hash`) instead of the full query; the edge
854    /// resolves + caches `hash → query` (saving bandwidth + parse cost).
855    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
856    pub persisted_queries: bool,
857    /// Safelist mode: only pre-registered query hashes run (a query allowlist); the edge
858    /// never registers a new query. Implies (and is stronger than) `persisted_queries`.
859    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
860    pub safelist: bool,
861    /// Federation gateway: this site is a supergraph gateway. A GraphQL query is planned
862    /// against the project's registered subgraphs and executed by dispatching fetches to
863    /// the subgraph functions (a subgraph's name is its function name), stitching the
864    /// results — instead of running a single handler component.
865    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
866    pub federated: bool,
867    /// Serve the GraphiQL in-browser explorer: a browser `GET` (an `Accept: text/html`
868    /// request) to the endpoint gets the IDE, which posts queries back to the same URL.
869    /// A developer convenience — off by default; pair with `introspection` for schema docs.
870    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
871    pub graphiql: bool,
872    /// Declarative data connector: serve the GraphQL API by generating it from a managed
873    /// database (queries compiled to SQL) instead of running a wasm handler. Absent unless
874    /// configured; exposure is deny-by-default.
875    #[serde(skip_serializing_if = "Option::is_none")]
876    pub data: Option<HandlerGraphqlDataConfig>,
877}
878
879/// The declarative GraphQL data connector's configuration (see
880/// [`HandlerGraphqlConfig::data`]). A database-derived API is **deny-by-default**: only the
881/// tables (and their columns) named here are exposed.
882#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
883#[serde(default, deny_unknown_fields)]
884pub struct HandlerGraphqlDataConfig {
885    /// Master switch. `false` (the default) ⇒ the connector is inert even if present.
886    pub enabled: bool,
887    /// The managed SQL database name to expose (the site's default database if unset).
888    #[serde(skip_serializing_if = "String::is_empty")]
889    pub source: String,
890    /// The exposed tables, keyed by table name. Deny-by-default: a table absent here is
891    /// neither in the generated schema nor queryable.
892    #[serde(skip_serializing_if = "std::collections::BTreeMap::is_empty")]
893    pub tables: std::collections::BTreeMap<String, HandlerGraphqlTableConfig>,
894    /// Allow mutations (insert/update/delete). Off by default — the connector is read-only
895    /// unless a site opts in.
896    #[serde(skip_serializing_if = "std::ops::Not::not")]
897    pub mutations: bool,
898    /// Bind `row_filter` claims from a **verified application bearer token** (the app's own
899    /// IdP), not only the host-asserted `project`. This unlocks multi-tenant-within-one-project
900    /// isolation: the app's tokens carry a tenant claim (e.g. `tid`) a `row_filter` scopes rows
901    /// by. A claim value is used **only** from a fully verified token; a missing/invalid token
902    /// leaves the claim absent, so a filter referencing it denies (never widens). Absent ⇒ only
903    /// the host `project` claim is available.
904    #[serde(skip_serializing_if = "Option::is_none")]
905    pub claims_from_token: Option<HandlerGraphqlTokenClaims>,
906}
907
908/// How to verify an application bearer token whose claims a `row_filter` may bind (see
909/// [`HandlerGraphqlDataConfig::claims_from_token`]). The token is verified against `issuer` +
910/// the JWKS (signature, `iss`, `exp`/`nbf`, `kid`); on success its scalar claims are merged in
911/// beside the host-asserted `project` (which a token can never override).
912#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
913#[serde(default, deny_unknown_fields)]
914pub struct HandlerGraphqlTokenClaims {
915    /// The expected token issuer (`iss`).
916    pub issuer: String,
917    /// A **host environment variable** holding the app IdP's JWKS JSON — the operator maps
918    /// the app's public JWKS in, exactly like a handler secret. Re-read per request, so a
919    /// rotated JWKS takes effect without a restart.
920    #[serde(skip_serializing_if = "Option::is_none")]
921    pub jwks_env: Option<String>,
922    /// Or a **URL** to fetch the JWKS from (cached per `kid`, refreshed on an unknown `kid` for
923    /// IdP key rollover). Operator-configured, so not a request-controlled fetch.
924    #[serde(skip_serializing_if = "Option::is_none")]
925    pub jwks_url: Option<String>,
926    /// An optional expected audience (`aud`); unset skips audience validation.
927    #[serde(skip_serializing_if = "Option::is_none")]
928    pub audience: Option<String>,
929}
930
931/// One exposed table's policy (see [`HandlerGraphqlDataConfig::tables`]).
932#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
933#[serde(default, deny_unknown_fields)]
934pub struct HandlerGraphqlTableConfig {
935    /// The readable columns (an allow-list). Deny-by-default: a column absent here is
936    /// invisible.
937    pub columns: Vec<String>,
938    /// Row-level filter terms, all applied to every access — the tenant-isolation seam.
939    #[serde(skip_serializing_if = "Vec::is_empty")]
940    pub row_filter: Vec<HandlerGraphqlRowTerm>,
941    /// Fields on this type resolved by a **wasm function** instead of a column: `field →
942    /// function name`. The connector resolves the row's columns from SQL, then batches one
943    /// invoke to the function (a local `_entities` fetch) to fill the field. This map is also
944    /// the allowlist — only these fields delegate, only to these functions.
945    #[serde(skip_serializing_if = "std::collections::BTreeMap::is_empty")]
946    pub resolvers: std::collections::BTreeMap<String, String>,
947}
948
949/// One row-filter term: the `column` must equal the value of the request's `claim` (see
950/// [`HandlerGraphqlTableConfig::row_filter`]). Claims are host-asserted (e.g. `project`).
951#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
952#[serde(default, deny_unknown_fields)]
953pub struct HandlerGraphqlRowTerm {
954    /// The constrained column.
955    pub column: String,
956    /// The request claim whose value the column must equal.
957    pub claim: String,
958}
959
960/// Per-site edge response-cache tuning (see [`HandlersSiteConfig::cache`]).
961#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
962#[serde(default, deny_unknown_fields)]
963pub struct HandlerCacheConfig {
964    /// Master switch. `false` (the default) ⇒ the cache is inert even if present.
965    pub enabled: bool,
966    /// Largest cacheable entry (encoded status+headers+body), in bytes; a bigger
967    /// response streams through uncached. `None` ⇒ the server default (256 KiB).
968    #[serde(skip_serializing_if = "Option::is_none")]
969    pub max_entry_bytes: Option<u64>,
970    /// Upper bound (seconds) on a stored entry's TTL, clamping an over-long
971    /// `max-age`. `None` ⇒ the server default (3600s).
972    #[serde(skip_serializing_if = "Option::is_none")]
973    pub max_ttl_secs: Option<u64>,
974}
975
976impl SiteConfig {
977    /// Parse from JSON (the KV storage / API format).
978    pub fn from_json(bytes: &[u8]) -> Result<Self, ConfigError> {
979        serde_json::from_slice(bytes).map_err(|err| ConfigError::parse(err.to_string()))
980    }
981
982    /// Serialize to JSON for KV storage.
983    pub fn to_json(&self) -> Result<Vec<u8>, ConfigError> {
984        serde_json::to_vec(self).map_err(|err| ConfigError::parse(err.to_string()))
985    }
986
987    /// Validate the site-level handler import allowlist: every entry in
988    /// [`HandlersSiteConfig::allow_imports`] must be a recognized import name (a
989    /// `KNOWN_IMPORTS` interface, a named SQL binding `sql:<name>`/`sql:*`, or an admin
990    /// surface `admin:<surface>`). Called on the guest `admin:site` config-write path so a
991    /// self-configuring guest can't stash an unknown/typo'd capability name in the allowlist.
992    /// Defense-in-depth: the effective runtime grant is still the deploy-pinned manifest
993    /// `imports` ∩ this list ∩ posture, so a bogus entry can't escalate — this rejects it early.
994    pub fn validate_allow_imports(&self) -> Result<(), ConfigError> {
995        if let Some(handlers) = &self.handlers {
996            for import in &handlers.allow_imports {
997                check_import(import)?;
998            }
999        }
1000        Ok(())
1001    }
1002}
1003
1004/// The hostnames a site answers to.
1005#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1006#[serde(default, deny_unknown_fields)]
1007pub struct DomainConfig {
1008    /// Primary/canonical hostname (e.g. `example.com`).
1009    pub primary: Option<String>,
1010    /// Additional exact hostnames (e.g. `www.example.com`).
1011    pub aliases: Vec<String>,
1012    /// Wildcard patterns (`*.example.com`), matched by suffix at any depth.
1013    pub wildcards: Vec<String>,
1014    /// Redirect exact-alias hosts to [`primary`](Self::primary) with a 301
1015    /// (apex↔www canonicalization). Only exact aliases redirect — wildcard hosts
1016    /// serve as-is. Off by default.
1017    pub canonical_redirect: bool,
1018    /// Per-host **tenant context tag** (Stage 0 in-site tenancy): host-or-wildcard-pattern → an
1019    /// opaque tag the host binds as the in-site tenant when a function/site resolves "own" via
1020    /// [`crate::tenancy::TenantSource::Domain`]. This is what lets ONE deployment serve many
1021    /// customer storefronts, each on its own domain = its own tenant. An exact host with no own
1022    /// entry inherits [`primary`](Self::primary)'s tag (apex↔www share a tenant); a subdomain
1023    /// inherits its matching wildcard's tag. A host with no resolvable tag makes the domain source
1024    /// fail closed (no cross-tenant read). The tag is bound as a parameter, never formatted into
1025    /// SQL.
1026    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
1027    pub contexts: BTreeMap<String, String>,
1028}
1029
1030impl DomainConfig {
1031    /// All exact hostnames (primary first, then aliases).
1032    pub fn exact_hosts(&self) -> impl Iterator<Item = &str> {
1033        self.primary
1034            .as_deref()
1035            .into_iter()
1036            .chain(self.aliases.iter().map(String::as_str))
1037    }
1038}
1039
1040/// Site-scoped **transport security** (the site config tier owns transport
1041/// concerns). Off by default; the operator opts in once TLS is in
1042/// front (directly or via a terminating proxy).
1043#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1044#[serde(default, deny_unknown_fields)]
1045pub struct SecurityConfig {
1046    /// 301 plain-HTTP requests to HTTPS. Proxy-aware: the effective scheme is
1047    /// read from `X-Forwarded-Proto` behind a TLS-terminating proxy.
1048    pub https_redirect: bool,
1049    /// Send `Strict-Transport-Security` on HTTPS responses, when set.
1050    #[serde(skip_serializing_if = "Option::is_none")]
1051    pub hsts: Option<Hsts>,
1052    /// `Content-Security-Policy` header value, when set (opt-in: a default CSP
1053    /// would break the inline scripts/styles common in static sites, so the
1054    /// operator supplies the policy). Applied on host-routed responses.
1055    #[serde(skip_serializing_if = "Option::is_none")]
1056    pub csp: Option<String>,
1057    /// `X-Frame-Options` header value (e.g. `DENY`, `SAMEORIGIN`), when set.
1058    /// Opt-in: it can break legitimate embedding, so it isn't a default.
1059    #[serde(skip_serializing_if = "Option::is_none")]
1060    pub frame_options: Option<String>,
1061}
1062
1063/// HTTP Strict-Transport-Security policy.
1064#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1065#[serde(default, deny_unknown_fields)]
1066pub struct Hsts {
1067    /// `max-age` in seconds.
1068    pub max_age: u64,
1069    /// Apply to subdomains too.
1070    pub include_subdomains: bool,
1071    /// Request inclusion in browser preload lists.
1072    pub preload: bool,
1073}
1074
1075impl Default for Hsts {
1076    fn default() -> Self {
1077        // One year + includeSubDomains: the common safe baseline (preload is an
1078        // explicit opt-in since it's hard to undo).
1079        Self {
1080            max_age: 31_536_000,
1081            include_subdomains: true,
1082            preload: false,
1083        }
1084    }
1085}
1086
1087impl Hsts {
1088    /// The `Strict-Transport-Security` header value.
1089    pub fn header_value(&self) -> String {
1090        let mut v = format!("max-age={}", self.max_age);
1091        if self.include_subdomains {
1092            v.push_str("; includeSubDomains");
1093        }
1094        if self.preload {
1095            v.push_str("; preload");
1096        }
1097        v
1098    }
1099}
1100
1101/// Compute the canonicalization/HTTPS **redirect target** for a request, or
1102/// `None` if it's already canonical. `scheme` is the
1103/// effective scheme (`http`/`https`, proxy-aware), `host` the request host
1104/// (no port), `path_and_query` the rest of the URL. A single 301 collapses both
1105/// an HTTPS upgrade and an apex↔www redirect.
1106pub fn transport_redirect(
1107    security: &SecurityConfig,
1108    domains: &DomainConfig,
1109    scheme: &str,
1110    host: &str,
1111    path_and_query: &str,
1112) -> Option<String> {
1113    let target_scheme = if security.https_redirect && scheme == "http" {
1114        "https"
1115    } else {
1116        scheme
1117    };
1118    // Only exact aliases canonicalize to the primary; wildcard hosts serve as-is.
1119    let target_host = match &domains.primary {
1120        Some(primary)
1121            if domains.canonical_redirect
1122                && primary != host
1123                && domains.aliases.iter().any(|a| a == host) =>
1124        {
1125            primary.as_str()
1126        }
1127        _ => host,
1128    };
1129    if target_scheme == scheme && target_host == host {
1130        return None;
1131    }
1132    Some(format!("{target_scheme}://{target_host}{path_and_query}"))
1133}
1134
1135#[cfg(test)]
1136mod tests {
1137    use super::*;
1138
1139    #[test]
1140    fn empty_config_uses_defaults() {
1141        let config = DeployConfig::from_ron("()").unwrap();
1142        assert_eq!(config.index, vec!["index.html".to_string()]);
1143        assert_eq!(config.trailing_slash, TrailingSlash::Preserve);
1144        assert!(config.redirects.is_empty());
1145    }
1146
1147    #[test]
1148    fn transport_redirect_https_canonical_and_noop() {
1149        let mut domains = DomainConfig {
1150            primary: Some("example.com".into()),
1151            aliases: vec!["www.example.com".into()],
1152            ..Default::default()
1153        };
1154        let mut sec = SecurityConfig::default();
1155
1156        // Defaults: nothing configured → no redirect.
1157        assert_eq!(
1158            transport_redirect(&sec, &domains, "http", "example.com", "/a?b=1"),
1159            None
1160        );
1161
1162        // HTTPS redirect only.
1163        sec.https_redirect = true;
1164        assert_eq!(
1165            transport_redirect(&sec, &domains, "http", "example.com", "/a?b=1").as_deref(),
1166            Some("https://example.com/a?b=1")
1167        );
1168        // Already https → no-op.
1169        assert_eq!(
1170            transport_redirect(&sec, &domains, "https", "example.com", "/a"),
1171            None
1172        );
1173
1174        // Canonical: an exact alias → primary (and HTTPS in one hop).
1175        domains.canonical_redirect = true;
1176        assert_eq!(
1177            transport_redirect(&sec, &domains, "http", "www.example.com", "/p").as_deref(),
1178            Some("https://example.com/p")
1179        );
1180        // The primary itself is canonical → only the scheme may change.
1181        assert_eq!(
1182            transport_redirect(&sec, &domains, "https", "example.com", "/p"),
1183            None
1184        );
1185        // A wildcard/non-alias host is NOT canonicalized (only the scheme).
1186        assert_eq!(
1187            transport_redirect(&sec, &domains, "https", "blog.example.com", "/p"),
1188            None
1189        );
1190        sec.https_redirect = false;
1191        assert_eq!(
1192            transport_redirect(&sec, &domains, "https", "www.example.com", "/p").as_deref(),
1193            Some("https://example.com/p"),
1194            "canonical redirect applies even without https_redirect"
1195        );
1196    }
1197
1198    #[test]
1199    fn hsts_header_value() {
1200        assert_eq!(
1201            Hsts::default().header_value(),
1202            "max-age=31536000; includeSubDomains"
1203        );
1204        assert_eq!(
1205            Hsts {
1206                max_age: 60,
1207                include_subdomains: false,
1208                preload: true
1209            }
1210            .header_value(),
1211            "max-age=60; preload"
1212        );
1213    }
1214
1215    #[test]
1216    fn secret_heuristic_flags_credentials_not_plain_config() {
1217        // Plain, legitimate `env` values are NOT flagged.
1218        for ok in [
1219            "info",
1220            "production",
1221            "https://api.example.com/v1",
1222            "3000",
1223            "en-US,en;q=0.9",
1224            "a-normal-kebab-case-flag",
1225        ] {
1226            assert!(!looks_like_secret(ok), "false positive on {ok:?}");
1227        }
1228        // Credential-shaped values ARE flagged.
1229        let pem = "-----BEGIN RSA PRIVATE KEY-----\nMIIabc\n-----END RSA PRIVATE KEY-----";
1230        for bad in [
1231            pem,
1232            "AKIAIOSFODNN7EXAMPLE",
1233            "ghp_16C7e42F292c6912E7710c838347Ae178B4a", // GitHub PAT shape
1234            "AIzaSyA-1234567890abcdefghijklmnopqrstuv", // Google API key shape
1235            "wJalrXUtnFEMI1bK7MDENGbPxRfiCYEXAMPLEKEY12", // mixed-case high-entropy
1236            "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef0123", // long hex
1237        ] {
1238            assert!(looks_like_secret(bad), "missed secret {bad:?}");
1239        }
1240    }
1241
1242    #[test]
1243    fn check_handlers_rejects_secret_in_env() {
1244        use std::collections::BTreeMap;
1245        let config = DeployConfig {
1246            handlers: vec![HandlerConfig {
1247                route: "/h".into(),
1248                methods: Vec::new(),
1249                component: "h.wasm".into(),
1250                imports: Vec::new(),
1251                streaming: false,
1252                limits: None,
1253                env: BTreeMap::from([("AWS_KEY".to_string(), "AKIAIOSFODNN7EXAMPLE".to_string())]),
1254                invoke_targets: Vec::new(),
1255            }],
1256            ..Default::default()
1257        };
1258        let err = config.compile_check().unwrap_err().to_string();
1259        assert!(err.contains("looks like a secret"), "got: {err}");
1260    }
1261
1262    #[test]
1263    fn check_handlers_validates_sessions() {
1264        // A well-formed session (compiling route, non-empty component, recognized imports incl.
1265        // the intrinsic `session` token) passes.
1266        let ok = DeployConfig {
1267            sessions: vec![SessionConfig {
1268                route: "/agent".into(),
1269                component: "agent.wasm".into(),
1270                imports: vec!["session".into(), "sql".into()],
1271                ..Default::default()
1272            }],
1273            ..Default::default()
1274        };
1275        ok.compile_check().expect("valid session config");
1276
1277        // An empty component path is rejected.
1278        let no_component = DeployConfig {
1279            sessions: vec![SessionConfig {
1280                route: "/agent".into(),
1281                component: String::new(),
1282                ..Default::default()
1283            }],
1284            ..Default::default()
1285        };
1286        assert!(no_component
1287            .compile_check()
1288            .unwrap_err()
1289            .to_string()
1290            .contains("empty component"));
1291
1292        // An unknown import is rejected.
1293        let bad_import = DeployConfig {
1294            sessions: vec![SessionConfig {
1295                route: "/agent".into(),
1296                component: "agent.wasm".into(),
1297                imports: vec!["not-a-capability".into()],
1298                ..Default::default()
1299            }],
1300            ..Default::default()
1301        };
1302        assert!(bad_import
1303            .compile_check()
1304            .unwrap_err()
1305            .to_string()
1306            .contains("unknown handler import"));
1307    }
1308
1309    #[test]
1310    fn parses_a_full_document() {
1311        let text = r#"(
1312            clean_urls: true,
1313            trailing_slash: Never,
1314            error_documents: { 404: "/404.html" },
1315            redirects: [ (from: "/old/:slug", to: "/new/:slug", status: 301) ],
1316            rewrites: [ (from: "/app/**", to: "/index.html") ],
1317            headers: [ (matches: "**.js", set: { "Cache-Control": "public, max-age=31536000, immutable" }) ],
1318            cache: ( default: "public, max-age=0, must-revalidate" ),
1319            mime_overrides: { ".webmanifest": "application/manifest+json" },
1320        )"#;
1321        let config = DeployConfig::from_ron(text).unwrap();
1322        assert!(config.clean_urls);
1323        assert_eq!(config.trailing_slash, TrailingSlash::Never);
1324        assert_eq!(config.redirects[0].status, 301);
1325        assert_eq!(config.rewrites[0].status, 200); // defaulted
1326        assert_eq!(
1327            config.error_documents.get(&404).map(String::as_str),
1328            Some("/404.html")
1329        );
1330    }
1331
1332    #[test]
1333    fn rejects_bad_pattern_at_parse() {
1334        let text = r#"( redirects: [ (from: "/a/**/b/**", to: "/x") ] )"#;
1335        assert!(DeployConfig::from_ron(text).is_err());
1336    }
1337
1338    #[test]
1339    fn rejects_unknown_field() {
1340        assert!(DeployConfig::from_ron("( nope: true )").is_err());
1341    }
1342
1343    #[test]
1344    fn accepts_named_sql_imports_and_rejects_malformed_ones() {
1345        use super::check_import;
1346        // The bare capability + named-database grants are accepted.
1347        assert!(check_import("sql").is_ok());
1348        assert!(check_import("sql:product").is_ok());
1349        assert!(check_import("sql:privileged_2-a").is_ok());
1350        assert!(check_import("sql:*").is_ok());
1351        // Malformed named grants are rejected: empty name, or a name that could smuggle a path /
1352        // injection through `sql.open(name)`.
1353        assert!(check_import("sql:").is_err());
1354        assert!(check_import("sql:a/b").is_err());
1355        assert!(check_import("sql:a b").is_err());
1356        // A wholly-unknown import is still rejected.
1357        assert!(check_import("wasi:filesystem").is_err());
1358    }
1359
1360    #[test]
1361    fn proxy_allow_list_matching() {
1362        // Empty list permits any host (the IP guard still applies separately).
1363        assert!(DeployConfig::default().proxy_host_allowed("anything.example"));
1364
1365        let cfg = DeployConfig {
1366            proxy_allow: vec!["api.example.com".into(), ".internal.test".into()],
1367            ..DeployConfig::default()
1368        };
1369        assert!(cfg.proxy_host_allowed("api.example.com")); // exact
1370        assert!(cfg.proxy_host_allowed("API.EXAMPLE.COM")); // case-insensitive
1371        assert!(cfg.proxy_host_allowed("a.internal.test")); // suffix
1372        assert!(cfg.proxy_host_allowed("internal.test")); // suffix apex
1373        assert!(!cfg.proxy_host_allowed("evil.com"));
1374        assert!(!cfg.proxy_host_allowed("notapi.example.com"));
1375    }
1376
1377    #[test]
1378    fn parses_handler_config() {
1379        let text = r#"(
1380            handlers: [
1381                ( route: "/api/orders/*", methods: ["GET", "POST"],
1382                  component: "handlers/orders.wasm",
1383                  imports: ["sql", "wasi:keyvalue", "wasi:messaging"],
1384                  limits: ( memory_mb: 64, timeout_ms: 10000 ),
1385                  env: { "LOG_LEVEL": "info" } ),
1386            ],
1387            consumers: [
1388                ( topic: "orders/created", component: "handlers/agg.wasm",
1389                  imports: ["sql"] ),
1390            ],
1391            crons: [ ( schedule: "0 */6 * * *", route: "/api/orders/reindex", overlap: Skip ) ],
1392            streams: [ ( route: "/events/orders", topics: ["orders/created"] ) ],
1393        )"#;
1394        let config = DeployConfig::from_ron(text).unwrap();
1395        assert_eq!(config.handlers.len(), 1);
1396        assert_eq!(config.handlers[0].imports.len(), 3);
1397        assert_eq!(
1398            config.handlers[0].limits.as_ref().unwrap().memory_mb,
1399            Some(64)
1400        );
1401        assert_eq!(config.consumers.len(), 1);
1402        assert_eq!(config.crons[0].overlap, Overlap::Skip);
1403        assert_eq!(config.streams[0].topics, vec!["orders/created".to_string()]);
1404    }
1405
1406    #[test]
1407    fn site_config_round_trips_through_json_and_ron() {
1408        // A fully-populated SiteConfig, exercising the newer handler sub-configs (cache,
1409        // graphql, cookie_auth) that the API stores and returns. `SiteConfig` has
1410        // `deny_unknown_fields`, so any serde drift — a field that serializes under one name
1411        // but deserializes under another, or one that silently drops — makes the round-trip
1412        // fail to parse or fail equality. This is the guard for the "config field doesn't
1413        // round-trip" class (write a config, can't read it back).
1414        let cfg = SiteConfig {
1415            handlers: Some(HandlersSiteConfig {
1416                enabled: true,
1417                allow_imports: vec!["sql".into(), "graphql".into()],
1418                cache: Some(HandlerCacheConfig {
1419                    enabled: true,
1420                    max_entry_bytes: Some(262_144),
1421                    max_ttl_secs: Some(600),
1422                }),
1423                graphql: Some(HandlerGraphqlConfig {
1424                    enabled: true,
1425                    federated: true,
1426                    max_depth: Some(12),
1427                    safelist: true,
1428                    ..Default::default()
1429                }),
1430                cookie_auth: Some(CookieAuthConfig {
1431                    cookie_name: "__Host-session".into(),
1432                    allowed_origins: vec!["https://app.example.com".into()],
1433                }),
1434                ..Default::default()
1435            }),
1436            ..Default::default()
1437        };
1438
1439        // JSON is the API wire format (PUT/GET /api/sites/:site/config); `from_json` is the
1440        // parser the server uses, so this exercises the exact path.
1441        let json = serde_json::to_vec(&cfg).unwrap();
1442        let from_json = SiteConfig::from_json(&json)
1443            .unwrap_or_else(|e| panic!("SiteConfig JSON round-trip failed to parse: {e}"));
1444        assert_eq!(
1445            from_json, cfg,
1446            "SiteConfig did not survive a JSON round-trip"
1447        );
1448
1449        // And RON (via the derived Deserialize), the on-disk config format.
1450        let ron = ron::ser::to_string(&cfg).unwrap();
1451        let from_ron: SiteConfig = ron::from_str(&ron)
1452            .unwrap_or_else(|e| panic!("SiteConfig RON round-trip failed to parse: {e}\n{ron}"));
1453        assert_eq!(from_ron, cfg, "SiteConfig did not survive a RON round-trip");
1454    }
1455
1456    #[test]
1457    fn handler_validation_rejects_bad_config() {
1458        // Unknown import.
1459        assert!(DeployConfig::from_ron(
1460            r#"( handlers: [ ( route: "/a", component: "a.wasm", imports: ["wasi:gpu"] ) ] )"#
1461        )
1462        .is_err());
1463        // Bad HTTP method.
1464        assert!(DeployConfig::from_ron(
1465            r#"( handlers: [ ( route: "/a", component: "a.wasm", methods: ["FETCH"] ) ] )"#
1466        )
1467        .is_err());
1468        // Cron route not served by any handler.
1469        assert!(DeployConfig::from_ron(
1470            r#"( handlers: [ ( route: "/a", component: "a.wasm" ) ],
1471                 crons: [ ( schedule: "* * * * *", route: "/nope" ) ] )"#
1472        )
1473        .is_err());
1474        // A cron whose route IS served validates.
1475        assert!(DeployConfig::from_ron(
1476            r#"( handlers: [ ( route: "/tasks/*", component: "a.wasm" ) ],
1477                 crons: [ ( schedule: "0 0 * * *", route: "/tasks/x" ) ] )"#
1478        )
1479        .is_ok());
1480    }
1481
1482    #[test]
1483    fn cron_schedule_validation() {
1484        for ok in [
1485            "* * * * *",
1486            "0 */6 * * *",
1487            "30 2 1 1 0",
1488            "0,15,30,45 9-17 * * 1-5",
1489        ] {
1490            assert!(check_cron_schedule(ok).is_ok(), "{ok} should be valid");
1491        }
1492        for bad in [
1493            "* * * *",     // 4 fields
1494            "60 * * * *",  // minute out of range
1495            "* 24 * * *",  // hour out of range
1496            "* * 0 * *",   // dom < 1
1497            "* * * 13 *",  // month > 12
1498            "*/0 * * * *", // zero step
1499            "5-1 * * * *", // descending range
1500        ] {
1501            assert!(check_cron_schedule(bad).is_err(), "{bad} should be invalid");
1502        }
1503    }
1504
1505    #[test]
1506    fn handler_free_config_omits_handler_fields() {
1507        // A static-only deploy serializes without any handler keys (so existing
1508        // manifest ids are unchanged).
1509        let json = serde_json::to_string(&DeployConfig::default()).unwrap();
1510        assert!(!json.contains("handlers"));
1511        assert!(!json.contains("crons"));
1512    }
1513
1514    #[test]
1515    fn schema_version_defaults_to_one() {
1516        // Optional in RON; defaults to 1.
1517        assert_eq!(DeployConfig::from_ron("()").unwrap().version, 1);
1518        assert_eq!(DeployConfig::from_ron("(version: 1)").unwrap().version, 1);
1519        assert_eq!(SiteConfig::default().version, 1);
1520        // A version-less stored SiteConfig still reads as v1.
1521        assert_eq!(SiteConfig::from_json(b"{}").unwrap().version, 1);
1522    }
1523}