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