use std::sync::Arc;
use serde::{Deserialize, Serialize};
use crate::harness::Runtime;
use crate::module::Module;
use crate::ports::{Captcha, Port};
use crate::problem::Problem;
use crate::problems::SLUGS;
use crate::surface::Surface;
use crate::venture::VentureEnv;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "kebab-case")]
pub enum RoutePolicy {
#[default]
Open,
HumanForm,
Signature,
SignedLink,
}
impl RoutePolicy {
#[must_use]
pub fn is_guarded(self) -> bool {
self != Self::Open
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct WriteGuards {
pub captcha_modules: Vec<String>,
pub signature_modules: Vec<String>,
pub signed_link_modules: Vec<String>,
}
impl WriteGuards {
#[must_use]
pub fn collect(modules: &[Arc<dyn Module>]) -> Self {
let mut guards = Self::default();
for module in modules {
let surface: Surface = module.surface();
let (mut form, mut signature) = Self::surface_flags(&surface);
let mut signed_link = surface
.actions
.iter()
.any(|action| action.policy == RoutePolicy::SignedLink);
if !form && !signature && !signed_link && module.public_writes() {
match module.public_write_policy() {
RoutePolicy::Signature => signature = true,
RoutePolicy::SignedLink => signed_link = true,
RoutePolicy::HumanForm | RoutePolicy::Open => form = true,
}
}
if form {
guards.captcha_modules.push(module.name().to_owned());
}
if signature {
guards.signature_modules.push(module.name().to_owned());
}
if signed_link {
guards.signed_link_modules.push(module.name().to_owned());
}
}
guards
}
#[must_use]
pub fn from_surface(module: &str, surface: &Surface) -> Self {
let (form, signature) = Self::surface_flags(surface);
let signed_link = surface
.actions
.iter()
.any(|action| action.policy == RoutePolicy::SignedLink);
Self {
captcha_modules: form.then(|| module.to_owned()).into_iter().collect(),
signature_modules: signature.then(|| module.to_owned()).into_iter().collect(),
signed_link_modules: signed_link.then(|| module.to_owned()).into_iter().collect(),
}
}
fn surface_flags(surface: &Surface) -> (bool, bool) {
let mut form = false;
let mut signature = false;
for action in &surface.actions {
if action.demands_captcha() {
form = true;
}
if action.policy == RoutePolicy::Signature {
signature = true;
}
}
(form, signature)
}
#[must_use]
pub fn needs_signer(&self) -> bool {
!self.signed_link_modules.is_empty()
}
#[must_use]
pub fn needs_captcha(&self) -> bool {
!self.captcha_modules.is_empty()
}
#[must_use]
pub fn needs_payments(&self) -> bool {
!self.signature_modules.is_empty()
}
}
#[must_use]
pub fn captcha_effective(runtime: Option<&Arc<dyn Runtime>>) -> bool {
runtime.is_some_and(|runtime| runtime.effectively_configured(Port::Captcha))
}
#[must_use]
pub fn payments_effective(runtime: Option<&Arc<dyn Runtime>>) -> bool {
runtime.is_some_and(|runtime| runtime.effectively_configured(Port::Payments))
}
pub const ALLOW_UNPROTECTED_WRITES: &str = "HARNESS_ALLOW_UNPROTECTED_WRITES";
#[must_use]
pub fn unprotected_writes_override(config: &dyn crate::config::Config) -> Option<String> {
config
.get(ALLOW_UNPROTECTED_WRITES)
.map(|reason| reason.trim().to_owned())
.filter(|reason| !reason.is_empty())
}
#[must_use]
pub fn deployed_env(compiled: VentureEnv, config: &dyn crate::config::Config) -> VentureEnv {
let declared = config
.get("ENV")
.as_deref()
.and_then(VentureEnv::parse)
.unwrap_or_default();
compiled.strictest(declared)
}
#[must_use]
pub fn env_disagreement(compiled: VentureEnv, deployed: VentureEnv) -> Option<String> {
(compiled != deployed).then(|| {
format!(
"this deployment declares ENV={} but the venture was compiled with VentureEnv::{compiled:?}: the production readiness gate at build time ran against the wrong environment. Treating it as {} — call `.env(VentureEnv::{deployed:?})` on the venture so the build refuses what the deployment cannot serve (issue #143)",
deployed.as_str(),
deployed.as_str(),
)
})
}
#[must_use]
pub fn signer_effective(runtime: Option<&Arc<dyn Runtime>>) -> bool {
runtime.is_some_and(|runtime| runtime.effectively_configured(Port::Signer))
}
#[must_use]
pub fn production_readiness(
env: VentureEnv,
guards: &WriteGuards,
runtime: Option<&Arc<dyn Runtime>>,
allow_no_captcha: Option<&str>,
) -> Vec<String> {
if env != VentureEnv::Production {
return Vec::new();
}
let mut errors = Vec::new();
if guards.needs_captcha() && !captcha_effective(runtime) && allow_no_captcha.is_none() {
errors.push(format!(
"production venture has captcha-guarded public writes from [{}] but the Captcha \
port is not effectively configured: provide it on the runtime with a bound \
adapter (Turnstile needs its secret and expected hostname) — `fz doctor \
--allow-no-captcha <reason>` overrides this check for previews only",
guards.captcha_modules.join(", ")
));
}
if guards.needs_signer() && !signer_effective(runtime) {
errors.push(format!(
"production venture has signed-link public writes from [{}] but the Signer port is not provided — the magic links, challenges and unsubscribe links those routes verify cannot be issued or checked without one (issue #143)",
guards.signed_link_modules.join(", ")
));
}
if guards.needs_payments() && !payments_effective(runtime) {
errors.push(format!(
"production venture has signature-guarded routes from [{}] but the Payments port \
is not provided — webhook deliveries cannot be verified (see the Inbox dedup \
ledger and the STRIPE_WEBHOOK_SECRET doctor rule)",
guards.signature_modules.join(", ")
));
}
errors
}
pub async fn verify_human_form(
captcha: Option<&Arc<dyn Captcha>>,
env: VentureEnv,
accepted_unprotected: bool,
token: Option<&str>,
remote_ip: Option<&str>,
instance: &str,
) -> Result<(), Problem> {
let refused = || Problem::new(&SLUGS.captcha_failed).instance(instance);
match captcha {
Some(captcha) => {
let Some(token) = token else {
return Err(refused());
};
match captcha.verify(token, remote_ip).await {
Ok(verdict) if verdict.ok => Ok(()),
_ => Err(refused()),
}
}
None if env == VentureEnv::Production && !accepted_unprotected => Err(refused()),
None => Ok(()),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Migrations;
use crate::ports::{CaptchaError, Verdict};
use crate::surface::Action;
use async_trait::async_trait;
struct StubCaptcha {
ok: bool,
transport_error: bool,
}
#[async_trait]
impl Captcha for StubCaptcha {
async fn verify(
&self,
_token: &str,
_remote_ip: Option<&str>,
) -> Result<Verdict, CaptchaError> {
if self.transport_error {
return Err(CaptchaError::Transport("down".to_owned()));
}
Ok(Verdict {
ok: self.ok,
reason: None,
})
}
}
fn port(ok: bool, transport_error: bool) -> Arc<dyn Captcha> {
Arc::new(StubCaptcha {
ok,
transport_error,
})
}
#[pollster::test]
async fn verified_tokens_pass_in_every_environment() {
for env in [
VentureEnv::Development,
VentureEnv::Staging,
VentureEnv::Production,
] {
verify_human_form(
Some(&port(true, false)),
env,
false,
Some("token"),
Some("203.0.113.7"),
"request-1",
)
.await
.expect("a verified token passes");
}
}
#[pollster::test]
async fn refused_missing_or_unreachable_verifications_fail_closed() {
let good = port(true, false);
let refused = port(false, false);
let unreachable = port(true, true);
for env in [VentureEnv::Development, VentureEnv::Production] {
assert!(
verify_human_form(Some(&refused), env, false, Some("t"), None, "r")
.await
.is_err()
);
assert!(
verify_human_form(Some(&unreachable), env, false, Some("t"), None, "r")
.await
.is_err()
);
assert!(
verify_human_form(Some(&good), env, false, None, None, "r")
.await
.is_err()
);
}
}
#[pollster::test]
async fn absent_port_refuses_production_and_allows_lower_envs() {
assert!(
verify_human_form(None, VentureEnv::Production, false, Some("t"), None, "r")
.await
.is_err()
);
for env in [VentureEnv::Development, VentureEnv::Staging] {
assert!(
verify_human_form(None, env, false, Some("t"), None, "r")
.await
.is_ok()
);
}
}
struct Guarded {
name: &'static str,
surface: Surface,
public_writes: bool,
public_write_policy: RoutePolicy,
}
impl Module for Guarded {
fn name(&self) -> &'static str {
self.name
}
fn version(&self) -> &'static str {
"0.0.0-test"
}
fn requires(&self) -> &'static [Port] {
&[]
}
fn public_writes(&self) -> bool {
self.public_writes
}
fn public_write_policy(&self) -> RoutePolicy {
self.public_write_policy
}
fn migrations(&self) -> Migrations {
Migrations::default()
}
fn validate_config(
&self,
_: &dyn crate::config::Config,
) -> Result<(), crate::config::ConfigError> {
Ok(())
}
fn surface(&self) -> Surface {
self.surface.clone()
}
fn router(&self, _: crate::ModuleContext) -> axum::Router {
axum::Router::new()
}
}
fn module(name: &'static str, actions: Vec<Action>, public_writes: bool) -> Arc<dyn Module> {
Arc::new(Guarded {
name,
surface: Surface {
actions,
views: vec![],
},
public_writes,
public_write_policy: RoutePolicy::HumanForm,
})
}
fn declaring(name: &'static str, policy: RoutePolicy) -> Arc<dyn Module> {
Arc::new(Guarded {
name,
surface: Surface {
actions: vec![],
views: vec![],
},
public_writes: true,
public_write_policy: policy,
})
}
#[test]
fn human_form_action_requires_captcha() {
let guards = WriteGuards::collect(&[module(
"forms",
vec![Action::post("join", "/").policy(RoutePolicy::HumanForm)],
false,
)]);
assert_eq!(guards.captcha_modules, vec!["forms".to_owned()]);
assert!(!guards.needs_payments());
}
#[test]
fn signature_action_never_requires_captcha() {
let guards = WriteGuards::collect(&[module(
"billing",
vec![Action::post("webhook", "/webhook").policy(RoutePolicy::Signature)],
true, )]);
assert!(guards.captcha_modules.is_empty());
assert_eq!(guards.signature_modules, vec!["billing".to_owned()]);
}
#[test]
fn undeclared_public_writer_falls_back_to_captcha() {
let guards = WriteGuards::collect(&[module("auth", Vec::new(), true)]);
assert_eq!(guards.captcha_modules, vec!["auth".to_owned()]);
}
#[test]
fn legacy_captcha_flag_alias_still_guards() {
let mut action = Action::post("join", "/");
action.captcha = true;
let guards = WriteGuards::collect(&[module("forms", vec![action], false)]);
assert_eq!(guards.captcha_modules, vec!["forms".to_owned()]);
}
#[test]
fn guarded_surface_does_not_need_the_fallback() {
let guards = WriteGuards::collect(&[module(
"waitlist",
vec![
Action::post("join", "/").captcha(),
Action::post("webhook", "/webhook").policy(RoutePolicy::Signature),
],
true,
)]);
assert_eq!(guards.captcha_modules, vec!["waitlist".to_owned()]);
assert_eq!(guards.signature_modules, vec!["waitlist".to_owned()]);
}
#[test]
fn development_venture_needs_nothing() {
let guards = WriteGuards::collect(&[module("forms", Vec::new(), true)]);
for env in [VentureEnv::Development, VentureEnv::Staging] {
assert!(
production_readiness(env, &guards, None, None).is_empty(),
"{env:?} must not gate"
);
}
}
#[test]
fn production_without_runtime_refuses_captcha_writes() {
let guards = WriteGuards::collect(&[module(
"forms",
vec![Action::post("join", "/").captcha()],
false,
)]);
let errors = production_readiness(VentureEnv::Production, &guards, None, None);
assert_eq!(errors.len(), 1);
assert!(errors[0].contains("forms"));
assert!(errors[0].contains("Captcha"));
assert!(
production_readiness(VentureEnv::Production, &guards, None, Some("preview")).is_empty()
);
}
#[test]
fn production_signature_without_payments_refuses() {
let guards = WriteGuards::collect(&[module(
"billing",
vec![Action::post("webhook", "/webhook").policy(RoutePolicy::Signature)],
false,
)]);
let errors = production_readiness(VentureEnv::Production, &guards, None, None);
assert_eq!(errors.len(), 1);
assert!(errors[0].contains("billing"));
assert!(errors[0].contains("Payments"));
assert_eq!(
production_readiness(VentureEnv::Production, &guards, None, Some("preview")).len(),
1
);
}
use crate::config::MapConfig;
#[test]
fn the_deployment_environment_is_the_stricter_of_the_two() {
let deployed = MapConfig::from_pairs([("ENV", "production")]);
assert_eq!(
deployed_env(VentureEnv::Development, &deployed),
VentureEnv::Production,
);
let downgrade = MapConfig::from_pairs([("ENV", "development")]);
assert_eq!(
deployed_env(VentureEnv::Production, &downgrade),
VentureEnv::Production,
);
assert_eq!(
deployed_env(VentureEnv::Staging, &MapConfig::default()),
VentureEnv::Staging,
);
assert!(env_disagreement(VentureEnv::Production, VentureEnv::Production).is_none());
let note = env_disagreement(VentureEnv::Development, VentureEnv::Production)
.expect("a compiled-vs-deployed mismatch is reported");
assert!(note.contains("ENV=production"), "{note}");
}
#[test]
fn a_surface_less_writer_declares_what_actually_guards_it() {
let guards = WriteGuards::collect(&[module("legacy", vec![], true)]);
assert_eq!(guards.captcha_modules, ["legacy"]);
assert!(!guards.needs_signer());
let guards = WriteGuards::collect(&[declaring("passkeys", RoutePolicy::SignedLink)]);
assert!(
guards.captcha_modules.is_empty(),
"a signed-link writer must not demand a captcha it never renders"
);
assert_eq!(guards.signed_link_modules, ["passkeys"]);
assert!(guards.needs_signer());
}
#[test]
fn a_signed_link_writer_still_has_a_production_requirement_of_its_own() {
let guards = WriteGuards::collect(&[declaring("passkeys", RoutePolicy::SignedLink)]);
let errors = production_readiness(VentureEnv::Production, &guards, None, None);
assert_eq!(errors.len(), 1, "{errors:?}");
assert!(errors[0].contains("signed-link"), "{}", errors[0]);
assert!(errors[0].contains("passkeys"), "{}", errors[0]);
assert!(production_readiness(VentureEnv::Staging, &guards, None, None).is_empty());
}
#[test]
fn a_declared_policy_on_an_action_still_wins_over_the_fallback() {
let guards = WriteGuards::collect(&[Arc::new(Guarded {
name: "mixed",
surface: Surface {
actions: vec![Action::post("join", "/join").policy(RoutePolicy::HumanForm)],
views: vec![],
},
public_writes: true,
public_write_policy: RoutePolicy::SignedLink,
})]);
assert_eq!(guards.captcha_modules, ["mixed"]);
assert!(
guards.signed_link_modules.is_empty(),
"the module-level fallback must not override a declared action"
);
}
}