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