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