use std::collections::{BTreeSet, HashSet};
use http::Method;
use schemars::{JsonSchema, Schema, SchemaGenerator};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::config::ConfigError;
use crate::module::{HARNESS_API, Module};
use crate::venture::Venture;
pub const SURFACE_API: u32 = 1;
pub const MAX_SIDECAR_SURFACE_BYTES: usize = 256 * 1024;
pub const MAX_SIDECAR_ACTIONS: usize = 64;
pub const MAX_SIDECAR_VIEWS: usize = 64;
pub const HINT_KEYWORDS: &[&str] = &[
"x-cf-label",
"x-cf-placeholder",
"x-cf-help",
"x-cf-widget",
"x-cf-hidden",
"x-cf-options",
];
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Audience {
Public,
Admin,
Link,
Subject,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "kebab-case")]
pub enum Outcome {
Accepted { message: String },
Redirect,
Json,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Action {
pub name: String,
#[serde(with = "method_serde")]
pub method: Method,
pub path: String,
pub audience: Audience,
#[serde(skip_serializing_if = "Option::is_none")]
pub input: Option<Schema>,
#[serde(skip_serializing_if = "Option::is_none")]
pub output: Option<Schema>,
pub outcome: Outcome,
#[serde(skip)]
pub policy: crate::route_policy::RoutePolicy,
pub captcha: bool,
#[serde(skip)]
pub verification: Option<crate::route_policy::SignatureVerification>,
}
impl Action {
#[must_use]
pub fn post(name: impl Into<String>, path: impl Into<String>) -> Self {
Self::new(name, Method::POST, path)
}
#[must_use]
pub fn get(name: impl Into<String>, path: impl Into<String>) -> Self {
Self::new(name, Method::GET, path)
.audience(Audience::Link)
.outcome(Outcome::Redirect)
}
#[must_use]
pub fn delete(name: impl Into<String>, path: impl Into<String>) -> Self {
Self::new(name, Method::DELETE, path)
.audience(Audience::Admin)
.outcome(Outcome::Json)
}
#[must_use]
pub fn new(name: impl Into<String>, method: Method, path: impl Into<String>) -> Self {
Self {
name: name.into(),
method,
path: path.into(),
audience: Audience::Public,
input: None,
output: None,
outcome: Outcome::Accepted {
message: "Thanks, you're in.".to_owned(),
},
policy: crate::route_policy::RoutePolicy::default(),
captcha: false,
verification: None,
}
}
#[must_use]
pub fn audience(mut self, audience: Audience) -> Self {
self.audience = audience;
self
}
#[must_use]
pub fn input<T: JsonSchema>(mut self) -> Self {
self.input = Some(schema_for::<T>());
self
}
#[must_use]
pub fn input_schema(mut self, schema: Schema) -> Self {
self.input = Some(schema);
self
}
#[must_use]
pub fn output<T: JsonSchema>(mut self) -> Self {
self.output = Some(schema_for::<T>());
self
}
#[must_use]
pub fn output_schema(mut self, schema: Schema) -> Self {
self.output = Some(schema);
self
}
#[must_use]
pub fn outcome(mut self, outcome: Outcome) -> Self {
self.outcome = outcome;
self
}
#[must_use]
pub fn accepted(self, message: impl Into<String>) -> Self {
self.outcome(Outcome::Accepted {
message: message.into(),
})
}
#[must_use]
pub fn captcha(mut self) -> Self {
self.policy = crate::route_policy::RoutePolicy::HumanForm;
self.captcha = true;
self
}
#[must_use]
pub fn policy(mut self, policy: crate::route_policy::RoutePolicy) -> Self {
self.policy = policy;
self.captcha = policy == crate::route_policy::RoutePolicy::HumanForm;
self
}
#[must_use]
pub fn verification(
mut self,
verification: crate::route_policy::SignatureVerification,
) -> Self {
self.verification = Some(verification);
self
}
#[must_use]
pub fn demands_captcha(&self) -> bool {
self.policy == crate::route_policy::RoutePolicy::HumanForm || self.captcha
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Column {
pub key: String,
pub label: String,
}
impl Column {
#[must_use]
pub fn new(key: impl Into<String>, label: impl Into<String>) -> Self {
Self {
key: key.into(),
label: label.into(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "kebab-case")]
pub enum View {
Form { action: String },
Status { action: String },
Table {
source: String,
columns: Vec<Column>,
},
}
impl View {
#[must_use]
pub fn form(action: impl Into<String>) -> Self {
View::Form {
action: action.into(),
}
}
#[must_use]
pub fn status(action: impl Into<String>) -> Self {
View::Status {
action: action.into(),
}
}
#[must_use]
pub fn table(source: impl Into<String>, columns: Vec<Column>) -> Self {
View::Table {
source: source.into(),
columns,
}
}
fn action_names(&self) -> Vec<&str> {
match self {
View::Form { action } | View::Status { action } => vec![action],
View::Table { source, .. } => vec![source],
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Surface {
pub actions: Vec<Action>,
pub views: Vec<View>,
}
impl Surface {
#[must_use]
pub fn none() -> Self {
Self::default()
}
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn action(mut self, action: Action) -> Self {
self.actions.push(action);
self
}
#[must_use]
pub fn view(mut self, view: View) -> Self {
self.views.push(view);
self
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.actions.is_empty() && self.views.is_empty()
}
pub fn validate(&self, module: &str, errors: &mut ConfigError) {
let mut seen: HashSet<&str> = HashSet::new();
for action in &self.actions {
let name = action.name.as_str();
if !is_kebab(name) {
errors.push(format!(
"module `{module}` surface action `{name}` must be kebab-case"
));
}
if !seen.insert(name) {
errors.push(format!(
"module `{module}` surface declares action `{name}` twice"
));
}
if !action.path.starts_with('/') {
errors.push(format!(
"module `{module}` surface action `{name}` path `{}` must start with '/' \
(relative to /v1/{module})",
action.path
));
}
if action.path.split('/').any(|segment| segment == "..") {
errors.push(format!(
"module `{module}` surface action `{name}` path `{}` must not contain '..' \
segments (an action stays inside its module's mount)",
action.path
));
}
let under_admin = action.path == "/admin" || action.path.starts_with("/admin/");
match action.audience {
Audience::Admin if !under_admin => errors.push(format!(
"module `{module}` surface action `{name}` is admin but its path `{}` \
is not under /admin/",
action.path
)),
Audience::Public | Audience::Link | Audience::Subject if under_admin => {
errors.push(format!(
"module `{module}` surface action `{name}` is under /admin/ but its \
audience is not admin",
));
}
_ => {}
}
if let Some(schema) = &action.input
&& !is_object_schema(schema)
{
errors.push(format!(
"module `{module}` surface action `{name}` input schema must describe an \
object (a struct with named fields), so a renderer can lay out fields"
));
}
if let Some(schema) = &action.output {
if !is_object_schema(schema) {
errors.push(format!(
"module `{module}` surface action `{name}` output schema must describe \
an object, the JSON body the action answers with"
));
}
if action.outcome != Outcome::Json {
errors.push(format!(
"module `{module}` surface action `{name}` declares an output schema \
but its outcome is not json; only a JSON body has a shape to publish"
));
}
}
match action.policy {
crate::route_policy::RoutePolicy::HumanForm if !action.captcha => {
errors.push(format!(
"module `{module}` surface action `{name}` declares policy=HumanForm \
but captcha=false; the renderer would omit the widget the policy \
demands — declare protection with `.captcha()` (issue #133)",
));
}
crate::route_policy::RoutePolicy::Signature if action.captcha => {
errors.push(format!(
"module `{module}` surface action `{name}` declares policy=Signature \
(a machine caller) but captcha=true; a captcha cannot protect a \
webhook — drop `.captcha()` (issue #133)",
));
}
crate::route_policy::RoutePolicy::ApiKey if action.captcha => {
errors.push(format!(
"module `{module}` surface action `{name}` declares policy=ApiKey \
(a machine caller) but captcha=true; a captcha cannot protect an \
API-key route — drop `.captcha()` (issue #532)",
));
}
_ => {}
}
validate_verifier(module, name, action, errors);
}
for view in &self.views {
for referenced in view.action_names() {
if !seen.contains(referenced) {
errors.push(format!(
"module `{module}` surface view references action `{referenced}` \
which the module does not declare"
));
}
}
}
}
#[must_use]
pub fn public(&self) -> Surface {
let actions: Vec<Action> = self
.actions
.iter()
.filter(|action| {
action.audience != Audience::Admin
&& action.policy != crate::route_policy::RoutePolicy::Signature
&& action.policy != crate::route_policy::RoutePolicy::ApiKey
})
.cloned()
.collect();
let names: BTreeSet<&str> = actions.iter().map(|a| a.name.as_str()).collect();
let views = self
.views
.iter()
.filter(|view| view.action_names().iter().all(|n| names.contains(n)))
.cloned()
.collect();
Surface { actions, views }
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModuleSurface {
pub name: String,
pub version: String,
#[serde(flatten)]
pub surface: Surface,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VentureSurface {
pub name: String,
pub public_url: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SurfaceDocument {
pub surface_api: u32,
pub harness_api: u32,
pub venture: VentureSurface,
pub modules: Vec<ModuleSurface>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ui: Option<serde_json::Value>,
}
impl SurfaceDocument {
#[must_use]
pub fn compose(venture: &Venture, modules: &[std::sync::Arc<dyn Module>]) -> Self {
let modules = modules
.iter()
.map(|module| ModuleSurface {
name: module.name().to_owned(),
version: module.version().to_owned(),
surface: module.surface(),
})
.filter(|entry| !entry.surface.is_empty())
.collect();
Self {
surface_api: SURFACE_API,
harness_api: HARNESS_API,
venture: VentureSurface {
name: venture.name.clone(),
public_url: venture.public_url.clone(),
},
modules,
ui: None,
}
}
#[must_use]
pub fn public(&self) -> Self {
Self {
surface_api: self.surface_api,
harness_api: self.harness_api,
venture: self.venture.clone(),
modules: self
.modules
.iter()
.map(|entry| ModuleSurface {
name: entry.name.clone(),
version: entry.version.clone(),
surface: entry.surface.public(),
})
.filter(|entry| !entry.surface.is_empty())
.collect(),
ui: self.ui.clone(),
}
}
}
pub(crate) fn sanitize_sidecar_document(
document: &SurfaceDocument,
mount: &str,
) -> Result<Vec<ModuleSurface>, Vec<String>> {
let mut errors = Vec::new();
if document.surface_api != SURFACE_API {
errors.push(format!(
"surface contract {} is not {SURFACE_API}",
document.surface_api
));
}
if document.harness_api != HARNESS_API {
errors.push(format!(
"harness contract {} is not {HARNESS_API}",
document.harness_api
));
}
let named: Vec<&ModuleSurface> = document
.modules
.iter()
.filter(|entry| entry.name == mount)
.collect();
match named.len() {
0 => {
if !document.modules.is_empty() {
let others: Vec<&str> = document.modules.iter().map(|m| m.name.as_str()).collect();
errors.push(format!(
"declares no surface for the mounted module `{mount}` (it names [{}] instead)",
others.join(", ")
));
}
}
1 => {}
count => errors.push(format!("declares module `{mount}` {count} times")),
}
if let Some(entry) = named.first() {
if entry.surface.actions.len() > MAX_SIDECAR_ACTIONS {
errors.push(format!(
"declares {} actions, more than the {MAX_SIDECAR_ACTIONS} a merged surface allows",
entry.surface.actions.len()
));
}
if entry.surface.views.len() > MAX_SIDECAR_VIEWS {
errors.push(format!(
"declares {} views, more than the {MAX_SIDECAR_VIEWS} a merged surface allows",
entry.surface.views.len()
));
}
let mut validation = ConfigError::default();
entry.surface.validate(mount, &mut validation);
errors.extend(validation.problems);
}
if !errors.is_empty() {
return Err(errors);
}
Ok(named
.into_iter()
.map(|entry| ModuleSurface {
name: entry.name.clone(),
version: entry.version.clone(),
surface: entry.surface.public(),
})
.collect())
}
pub(crate) fn strip_unguarded_captcha_actions(surface: &mut Surface) -> usize {
let kept: Vec<Action> = surface
.actions
.iter()
.filter(|action| !action.demands_captcha())
.cloned()
.collect();
let dropped = surface.actions.len() - kept.len();
if dropped > 0 {
let names: std::collections::BTreeSet<&str> =
kept.iter().map(|a| a.name.as_str()).collect();
surface
.views
.retain(|view| view.action_names().iter().all(|n| names.contains(n)));
surface.actions = kept;
}
dropped
}
#[async_trait::async_trait]
pub trait SurfaceSource: Send + Sync {
async fn current(&self) -> std::sync::Arc<SurfaceDocument>;
fn built(&self) -> std::sync::Arc<SurfaceDocument>;
fn rendered(&self, admin: bool) -> Option<&RenderedSurface> {
let _ = admin;
None
}
}
pub struct UiContext {
pub surface: std::sync::Arc<dyn SurfaceSource>,
pub api: axum::Router,
pub config: std::sync::Arc<dyn crate::config::Config>,
pub venture: std::sync::Arc<Venture>,
pub captcha_configured: bool,
pub signer: Option<std::sync::Arc<dyn crate::ports::Signer>>,
pub rate_limiter: Option<std::sync::Arc<dyn crate::ports::RateLimiter>>,
}
pub trait UiMount: Send + Sync + 'static {
fn router(&self, ctx: UiContext) -> axum::Router;
fn validate(&self, surface: &SurfaceDocument, errors: &mut ConfigError) {
let _ = (surface, errors);
}
fn describe(&self) -> Option<serde_json::Value> {
None
}
}
#[derive(Debug, Clone)]
pub struct RenderedSurface {
pub json: String,
pub etag: String,
}
impl RenderedSurface {
#[must_use]
pub fn render(document: &SurfaceDocument) -> Self {
let json = serde_json::to_string(document).unwrap_or_else(|_| "{}".to_owned());
let digest = Sha256::digest(json.as_bytes());
let mut hex = String::with_capacity(32);
for byte in &digest[..16] {
use std::fmt::Write as _;
let _ = write!(hex, "{byte:02x}");
}
Self {
json,
etag: format!("\"{hex}\""),
}
}
}
#[must_use]
pub fn schema_for<T: JsonSchema>() -> Schema {
let mut settings = schemars::generate::SchemaSettings::draft2020_12();
settings.inline_subschemas = true;
SchemaGenerator::new(settings).into_root_schema_for::<T>()
}
pub fn hint_field(schema: &mut Schema, field: &str, key: &str, value: serde_json::Value) {
if let Some(properties) = schema
.as_object_mut()
.and_then(|root| root.get_mut("properties"))
.and_then(serde_json::Value::as_object_mut)
&& let Some(property) = properties
.get_mut(field)
.and_then(serde_json::Value::as_object_mut)
{
property.insert(key.to_owned(), value);
}
}
fn is_object_schema(schema: &Schema) -> bool {
let value = schema.as_value();
match value.get("type") {
Some(serde_json::Value::String(t)) => t == "object",
Some(serde_json::Value::Array(types)) => types.iter().any(|t| t == "object"),
_ => value.get("properties").is_some(),
}
}
fn validate_verifier(module: &str, name: &str, action: &Action, errors: &mut ConfigError) {
if action.verification.is_some() && action.policy != crate::route_policy::RoutePolicy::Signature
{
errors.push(format!(
"module `{module}` surface action `{name}` names a signature verifier but its \
policy is not Signature; only a webhook route can say how it verifies deliveries \
— drop `.verification(..)` or declare `.policy(RoutePolicy::Signature)` \
(issue #595)",
));
}
}
fn is_kebab(name: &str) -> bool {
!name.is_empty()
&& name.split('-').all(|part| {
!part.is_empty()
&& part
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
})
}
mod method_serde {
use http::Method;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
pub(crate) fn serialize<S: Serializer>(
method: &Method,
serializer: S,
) -> Result<S::Ok, S::Error> {
method.as_str().serialize(serializer)
}
pub(crate) fn deserialize<'de, D: Deserializer<'de>>(
deserializer: D,
) -> Result<Method, D::Error> {
let text = String::deserialize(deserializer)?;
Method::from_bytes(text.as_bytes()).map_err(serde::de::Error::custom)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(JsonSchema)]
#[allow(dead_code)]
struct JoinBody {
#[schemars(extend("x-cf-label" = "Email", "x-cf-widget" = "email"))]
email: String,
product: String,
#[schemars(extend("x-cf-hidden" = true))]
#[serde(rename = "captchaToken")]
captcha_token: Option<String>,
}
fn join() -> Action {
Action::post("join", "/").input::<JoinBody>().captcha()
}
fn errors_of(surface: &Surface) -> Vec<String> {
let mut errors = ConfigError::default();
surface.validate("waitlist", &mut errors);
match errors.into_result() {
Ok(()) => Vec::new(),
Err(err) => err.to_string().lines().skip(1).map(str::to_owned).collect(),
}
}
#[test]
fn schema_carries_hints_and_is_inlined() {
let schema = schema_for::<JoinBody>();
let value = schema.as_value();
assert_eq!(value["type"], "object");
assert_eq!(value["properties"]["email"]["x-cf-label"], "Email");
assert_eq!(value["properties"]["email"]["x-cf-widget"], "email");
assert_eq!(value["properties"]["captchaToken"]["x-cf-hidden"], true);
assert!(value.get("$defs").is_none(), "subschemas must be inlined");
}
#[test]
fn hint_field_sets_a_keyword_and_ignores_unknown_fields() {
let mut schema = schema_for::<JoinBody>();
hint_field(
&mut schema,
"product",
"enum",
serde_json::json!(["a", "b"]),
);
hint_field(&mut schema, "missing", "x-cf-label", serde_json::json!("x"));
let value = schema.as_value();
assert_eq!(
value["properties"]["product"]["enum"],
serde_json::json!(["a", "b"])
);
assert!(value["properties"].get("missing").is_none());
}
#[test]
fn valid_surface_has_no_errors() {
let surface = Surface::new()
.action(join())
.action(Action::get("confirm", "/confirm"))
.action(
Action::get("export", "/admin/export.csv")
.audience(Audience::Admin)
.outcome(Outcome::Json)
.output::<JoinBody>(),
)
.view(View::form("join"))
.view(View::table("export", vec![Column::new("email", "Email")]));
assert!(errors_of(&surface).is_empty());
}
#[test]
fn every_validation_rule_names_the_module_and_action() {
#[derive(JsonSchema)]
#[allow(dead_code)]
struct NotAnObject(Vec<String>);
let surface = Surface::new()
.action(join())
.action(join())
.action(Action::post("Bad Name", "no-slash"))
.action(Action::post("hidden", "/admin/thing"))
.action(Action::delete("wipe", "/wipe"))
.action(Action::post("list", "/list").input::<NotAnObject>())
.action(
Action::get("rows", "/rows")
.outcome(Outcome::Json)
.output::<NotAnObject>(),
)
.action(Action::post("send", "/send").output::<JoinBody>())
.view(View::form("missing"));
let errors = errors_of(&surface);
let joined = errors.join("\n");
for needle in [
"declares action `join` twice",
"action `Bad Name` must be kebab-case",
"path `no-slash` must start with '/'",
"action `hidden` is under /admin/ but its audience is not admin",
"action `wipe` is admin but its path `/wipe` is not under /admin/",
"action `list` input schema must describe an object",
"action `rows` output schema must describe an object",
"action `send` declares an output schema but its outcome is not json",
"view references action `missing`",
] {
assert!(joined.contains(needle), "missing `{needle}` in:\n{joined}");
}
assert!(joined.lines().all(|l| l.contains("`waitlist`")), "{joined}");
}
#[test]
fn public_subset_drops_admin_actions_and_their_views() {
let surface = Surface::new()
.action(join())
.action(
Action::get("export", "/admin/export.csv")
.audience(Audience::Admin)
.outcome(Outcome::Json),
)
.view(View::form("join"))
.view(View::table("export", vec![]));
let public = surface.public();
assert_eq!(public.actions.len(), 1);
assert_eq!(public.views.len(), 1);
assert!(matches!(public.views[0], View::Form { .. }));
}
#[test]
fn rendered_surface_etag_is_stable_and_differs_per_variant() {
let doc = SurfaceDocument {
surface_api: SURFACE_API,
harness_api: HARNESS_API,
venture: VentureSurface {
name: "v".into(),
public_url: "https://v.test".into(),
},
modules: vec![ModuleSurface {
name: "waitlist".into(),
version: "0.1.0".into(),
surface: Surface::new()
.action(join())
.action(Action::delete("wipe", "/admin/wipe")),
}],
ui: None,
};
let full = RenderedSurface::render(&doc);
let again = RenderedSurface::render(&doc);
let public = RenderedSurface::render(&doc.public());
assert_eq!(full.etag, again.etag);
assert_ne!(full.etag, public.etag);
assert!(full.etag.starts_with('"') && full.etag.ends_with('"'));
assert_eq!(full.etag.len(), 34);
let parsed: serde_json::Value = serde_json::from_str(&full.json).unwrap();
assert_eq!(parsed["modules"][0]["actions"][0]["method"], "POST");
assert_eq!(parsed["modules"][0]["actions"][1]["audience"], "admin");
assert_eq!(parsed["surface_api"], SURFACE_API);
}
#[test]
fn document_omits_modules_without_a_surface() {
struct Silent;
impl Module for Silent {
fn name(&self) -> &'static str {
"silent"
}
fn version(&self) -> &'static str {
"0.0.0"
}
fn requires(&self) -> &'static [crate::ports::Port] {
&[]
}
fn migrations(&self) -> crate::module::Migrations {
crate::module::Migrations::EMPTY
}
fn validate_config(&self, _: &dyn crate::config::Config) -> Result<(), ConfigError> {
Ok(())
}
fn router(&self, _: crate::module::ModuleContext) -> axum::Router {
axum::Router::new()
}
}
let venture = Venture::new("v", "v.test");
let modules: Vec<std::sync::Arc<dyn Module>> = vec![std::sync::Arc::new(Silent)];
let doc = SurfaceDocument::compose(&venture, &modules);
assert!(doc.modules.is_empty());
}
#[test]
fn an_api_key_action_refuses_a_captcha_and_hides_from_the_public_surface() {
let mut action =
Action::post("sync", "/sync").policy(crate::route_policy::RoutePolicy::ApiKey);
action.captcha = true;
let errors = errors_of(&Surface::new().action(action));
assert!(
errors.iter().any(|error| error.contains("policy=ApiKey")),
"{errors:?}"
);
let surface = Surface::new()
.action(Action::post("sync", "/sync").policy(crate::route_policy::RoutePolicy::ApiKey))
.action(join());
let public = surface.public();
assert!(public.actions.iter().all(|action| action.name != "sync"));
assert!(public.actions.iter().any(|action| action.name == "join"));
}
#[test]
fn only_a_signature_action_may_name_a_verifier() {
let stray = Action::post("join", "/").captcha().verification(
crate::route_policy::SignatureVerification::Hmac {
secret: "WEBHOOK_SECRET",
},
);
let errors = errors_of(&Surface::new().action(stray));
assert!(
errors
.iter()
.any(|error| error.contains("policy is not Signature")),
"{errors:?}"
);
let accepted = Surface::new()
.action(
Action::post("stripe", "/webhooks/stripe")
.policy(crate::route_policy::RoutePolicy::Signature),
)
.action(
Action::post("revenuecat", "/webhooks/revenuecat")
.policy(crate::route_policy::RoutePolicy::Signature)
.verification(crate::route_policy::SignatureVerification::Hmac {
secret: "REVENUECAT_WEBHOOK_SECRET",
}),
);
assert!(errors_of(&accepted).is_empty());
}
}