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