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