hecks 3.6.0 → 3.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (146) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/bluebook/meta_validator/modes.rb +14 -0
  3. data/lib/hecks/bluebook/meta_validator/syntax_boot/disk_cache.rb +28 -3
  4. data/lib/hecks/bluebook/meta_validator/verdict_cache/prebuilt.rb +44 -0
  5. data/lib/hecks/bluebook/meta_validator/verdict_cache.rb +3 -17
  6. data/lib/hecks/bluebook/meta_validator.rb +4 -2
  7. data/lib/hecks/cache_dir.rb +10 -0
  8. data/lib/hecks/corpus/routes.rb +1 -1
  9. data/lib/hecks/deploy/bluebook/deploy.bluebook +109 -0
  10. data/lib/hecks/deploy/oidc.json +10 -0
  11. data/lib/hecks/doors/usage_cache.rb +19 -9
  12. data/lib/hecks/fuzzing/coverage_campaign.rb +42 -17
  13. data/lib/hecks/fuzzing/failure_triage.rb +80 -0
  14. data/lib/hecks/fuzzing/form_census/chapter_forms.rb +38 -0
  15. data/lib/hecks/fuzzing/form_census/reference_forms.rb +49 -0
  16. data/lib/hecks/fuzzing/form_census.rb +28 -52
  17. data/lib/hecks/fuzzing/isolated_boot/postgres_scratch.rb +22 -7
  18. data/lib/hecks/fuzzing/isolated_boot/scratch_schema.rb +27 -0
  19. data/lib/hecks/fuzzing/isolated_boot.rb +6 -2
  20. data/lib/hecks/fuzzing/mutation/checks.rb +76 -0
  21. data/lib/hecks/fuzzing/mutation/finders.rb +136 -0
  22. data/lib/hecks/fuzzing/mutation/judge.rb +94 -0
  23. data/lib/hecks/fuzzing/mutation/observation.rb +38 -0
  24. data/lib/hecks/fuzzing/mutation/operators.rb +52 -0
  25. data/lib/hecks/fuzzing/mutation/report.rb +78 -0
  26. data/lib/hecks/fuzzing/mutation/run.rb +99 -0
  27. data/lib/hecks/fuzzing/mutation/selection.rb +32 -0
  28. data/lib/hecks/fuzzing/mutation/site.rb +21 -0
  29. data/lib/hecks/fuzzing/mutation/source_map.rb +59 -0
  30. data/lib/hecks/fuzzing/mutation.rb +12 -0
  31. data/lib/hecks/fuzzing/properties/catalog.rb +10 -59
  32. data/lib/hecks/fuzzing/properties/guaranteed_by_construction.rb +63 -0
  33. data/lib/hecks/fuzzing/properties/read_model_names.rb +50 -0
  34. data/lib/hecks/fuzzing/properties/read_models.rb +138 -0
  35. data/lib/hecks/fuzzing/properties/role_checks.rb +50 -0
  36. data/lib/hecks/fuzzing/properties.rb +7 -0
  37. data/lib/hecks/fuzzing/replay/observation.rb +1 -1
  38. data/lib/hecks/fuzzing/replay/role_check.rb +49 -0
  39. data/lib/hecks/fuzzing/replay/session.rb +22 -6
  40. data/lib/hecks/fuzzing/replay.rb +1 -0
  41. data/lib/hecks/fuzzing/runtime_coverage.rb +70 -0
  42. data/lib/hecks/fuzzing/shrinker/value_simplification.rb +64 -0
  43. data/lib/hecks/fuzzing/shrinker.rb +3 -0
  44. data/lib/hecks/fuzzing.rb +3 -0
  45. data/lib/hecks/gate/stages.yml +12 -2
  46. data/lib/hecks/hecks/adapters/deploy_toolchain/answers.rb +2 -1
  47. data/lib/hecks/hecks/adapters/github_rulesets.rb +7 -5
  48. data/lib/hecks/hecks/adapters/process_pool.rb +26 -4
  49. data/lib/hecks/hecks/adapters/site_toolchain/prober.rb +64 -0
  50. data/lib/hecks/hecks/adapters/site_toolchain.rb +41 -5
  51. data/lib/hecks/hecks/custodian.behaviors +6 -0
  52. data/lib/hecks/hecks/custodian.bluebook +37 -0
  53. data/lib/hecks/hecks/hecks.hecksagon +6 -0
  54. data/lib/hecks/language/bluebook/vocabulary.bluebook +4 -1
  55. data/lib/hecks/projections/deploy/box/roll_scripts.rb +6 -4
  56. data/lib/hecks/projections/deploy/box/settings/patterns.rb +4 -0
  57. data/lib/hecks/projections/deploy/box/settings/types.rb +7 -3
  58. data/lib/hecks/projections/deploy/box/settings.rb +18 -0
  59. data/lib/hecks/projections/deploy/box/shared_database.rb +69 -0
  60. data/lib/hecks/projections/deploy/box/templates/Makefile.tmpl +6 -7
  61. data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +1 -1
  62. data/lib/hecks/projections/deploy/box/templates/provision-database.sh.tmpl +97 -0
  63. data/lib/hecks/projections/deploy/box/templates/restore-to-rds.sh.tmpl +4 -2
  64. data/lib/hecks/projections/deploy/box/templates/verify-copy.sh.tmpl +2 -1
  65. data/lib/hecks/projections/deploy/box.rb +14 -2
  66. data/lib/hecks/projections/deploy/shared_database_instance/templates/Makefile.tmpl +11 -0
  67. data/lib/hecks/projections/deploy/shared_database_instance/templates/README.md.tmpl +25 -0
  68. data/lib/hecks/projections/deploy/shared_database_instance/templates/rds.yaml.tmpl +180 -0
  69. data/lib/hecks/projections/deploy/shared_database_instance.rb +106 -0
  70. data/lib/hecks/projections/deploy/vercel/settings.rb +83 -0
  71. data/lib/hecks/projections/deploy/vercel.rb +134 -0
  72. data/lib/hecks/projections/site/probe.rb +131 -0
  73. data/lib/hecks/projections/site/site_host/dockerfile.rb +1 -1
  74. data/lib/hecks/projections/site/site_host/templates.rb +12 -4
  75. data/lib/hecks/projections.rb +2 -0
  76. data/lib/hecks/quality_control/adapters/github_checks.rb +9 -2
  77. data/lib/hecks/quality_control/cli/qa_sweep/dials.rb +1 -0
  78. data/lib/hecks/quality_control/cli/qa_sweep/seed_loop.rb +10 -2
  79. data/lib/hecks/runtime/value/validation.rb +34 -3
  80. data/lib/hecks/rust_build/native_build.rb +29 -1
  81. data/lib/hecks/rust_build/wasm.rb +16 -3
  82. data/lib/hecks/site/bluebook/site.bluebook +31 -0
  83. data/lib/hecks/site/bluebook/site.ports.hecksagon +6 -0
  84. data/lib/hecks/tools/deploy_recipe/generation.rb +59 -0
  85. data/lib/hecks/tools/deploy_recipe/targets.rb +56 -2
  86. data/lib/hecks/tools/deploy_recipe/worlds.rb +2 -1
  87. data/lib/hecks/tools/deploy_recipe.rb +20 -10
  88. data/lib/hecks/tools/fuzz_sweep/options.rb +21 -10
  89. data/lib/hecks/tools/fuzz_sweep/reporting.rb +25 -3
  90. data/lib/hecks/tools/fuzz_sweep.rb +26 -9
  91. data/lib/hecks/tools/lanes/files.rb +2 -4
  92. data/lib/hecks/tools/lanes/live.rb +3 -4
  93. data/lib/hecks/tools/lanes/ruleset.rb +23 -1
  94. data/lib/hecks/tools/lanes.rb +12 -1
  95. data/lib/hecks/tools/mutation_run.rb +73 -0
  96. data/lib/hecks/tools.rb +1 -0
  97. data/lib/hecks/version.rb +1 -1
  98. data/lib/hecks/vocabulary.rb +3 -1
  99. data/prebuilt/4e6c3cd1f578a38732ea5454dae52562d03d8e9886216077a5c34cbf589f3463.marshal +0 -0
  100. data/prebuilt/911cdcc56b5a094ed26583a4c76401dba6455b1f434722ed22b850bc1b1bdd28.marshal +0 -0
  101. data/prebuilt/verdicts-3230a251570aa03666708d16f92e646feab6a3f8cc2a3540c017d748e41d41a1.json +1 -0
  102. data/qa/settings.yml +1 -0
  103. data/rust/codegen/src/fielded.rs +3 -3
  104. data/rust/codegen/src/json_codec.rs +2 -2
  105. data/rust/codegen/src/naming.rs +20 -0
  106. data/rust/host/Cargo.toml +6 -2
  107. data/rust/host/HECKS_RELEASE +1 -1
  108. data/rust/host/src/api.rs +28 -5
  109. data/rust/host/src/auth.rs +11 -1
  110. data/rust/host/src/boundary_fuzz/api.rs +260 -0
  111. data/rust/host/src/boundary_fuzz/auth.rs +119 -0
  112. data/rust/host/src/boundary_fuzz/expr_json.rs +158 -0
  113. data/rust/host/src/boundary_fuzz/ir.rs +79 -0
  114. data/rust/host/src/boundary_fuzz/server.rs +227 -0
  115. data/rust/host/src/boundary_fuzz/web.rs +125 -0
  116. data/rust/host/src/commerce_ir.rs +459 -0
  117. data/rust/host/src/extension.rs +100 -0
  118. data/rust/host/src/fuzz_support.rs +165 -0
  119. data/rust/host/src/ir.rs +4 -448
  120. data/rust/host/src/journal.rs +1 -1
  121. data/rust/host/src/lib.rs +39 -0
  122. data/rust/host/src/main.rs +10 -44
  123. data/rust/host/src/mint.rs +1 -1
  124. data/rust/host/src/payments/tests.rs +8 -8
  125. data/rust/host/src/payments.rs +10 -10
  126. data/rust/host/src/rate_limit.rs +27 -41
  127. data/rust/host/src/secrets.rs +44 -0
  128. data/rust/host/src/server.rs +6 -1
  129. data/rust/host/src/web/commerce.rs +113 -0
  130. data/rust/host/src/web/newsletter.rs +2 -1
  131. data/rust/host/src/web/newsletter_send.rs +1 -1
  132. data/rust/host/src/web/registration_receipt.rs +2 -2
  133. data/rust/host/src/web/registrations.rs +49 -49
  134. data/rust/host/src/web/resend_webhook.rs +299 -0
  135. data/rust/host/src/web.rs +55 -49
  136. data/rust/parser/fuzz/.gitignore +4 -0
  137. data/rust/parser/fuzz/Cargo.lock +89 -0
  138. data/rust/parser/fuzz/Cargo.toml +27 -0
  139. data/rust/parser/fuzz/README.md +19 -0
  140. data/rust/parser/fuzz/fuzz_targets/chapter.rs +50 -0
  141. data/rust/parser/fuzz/fuzz_targets/resolve.rs +35 -0
  142. data/rust/parser/src/parse/command.rs +14 -2
  143. data/rust/parser/src/parse/mod.rs +25 -0
  144. data/rust/parser/src/ruby_value.rs +95 -6
  145. data/rust/src/kernel/json.rs +34 -3
  146. metadata +58 -2
@@ -1,35 +1,7 @@
1
1
  //! Lambda custom-runtime entry point (or a long-lived server under
2
2
  //! HECKS_SERVE_MODE=1): boots Postgres/wasm state, then dispatches events.
3
3
 
4
- mod api;
5
- mod approval;
6
- mod auth;
7
- mod checkout;
8
- mod dispatch;
9
- mod expr_json;
10
- mod field_hints;
11
- mod ir;
12
- mod journal;
13
- mod lambda_client;
14
- mod log;
15
- mod mint;
16
- mod needs;
17
- mod payments;
18
- mod presentation;
19
- mod presentation_write;
20
- mod query_step;
21
- mod rate_limit;
22
- mod reference_transform;
23
- mod reference_validate;
24
- mod resend;
25
- mod secrets;
26
- mod server;
27
- mod storage_shape;
28
- #[cfg(test)]
29
- mod test_pg;
30
- mod ui_schema;
31
- mod wasm_runner;
32
- mod web;
4
+ use rust_host::{approval, auth, extension, ir, journal, lambda_client, log, mint, rate_limit, secrets, server, storage_shape};
33
5
 
34
6
  use lambda_runtime::{service_fn, Error, LambdaEvent};
35
7
  use std::path::PathBuf;
@@ -69,10 +41,10 @@ async fn main() -> Result<(), Error> {
69
41
  .fetch_secret_string(&secret_arn)
70
42
  .await
71
43
  .map_err(|e| format!("fetching DB_SECRET_ARN from Secrets Manager: {e:#}"))?;
72
- let password = secrets::extract_field(&secret_json, "password")?;
73
- // Literal, unescaped, no percent-encoding -- parse_database_url
74
- // below never percent-decodes the password segment either way.
75
- format!("postgres://postgres:{password}@{db_host}:5432/{db_name}")
44
+ // The user and port come from the secret too (a client sharing an instance logs in as
45
+ // its own role); the password goes in literal, since parse_database_url below never
46
+ // percent-decodes it.
47
+ secrets::database_url(&secret_json, &db_host, &db_name)?
76
48
  }
77
49
  Err(_) => std::env::var("DATABASE_URL")
78
50
  .map_err(|_| "either DB_SECRET_ARN (+ DB_HOST/DB_NAME) or DATABASE_URL is required")?,
@@ -108,16 +80,8 @@ async fn main() -> Result<(), Error> {
108
80
  std::env::set_var("SESSION_SECRET", session_secret);
109
81
  }
110
82
  }
111
- // RESEND_SECRET_ID: fetched the same way, but a failed fetch only
112
- // logs -- mail is optional, and a missing secret must not stop the
113
- // host from serving everything else (resend.rs answers 503 without
114
- // a key).
115
- if let Ok(resend_secret_id) = std::env::var("RESEND_SECRET_ID") {
116
- let fetched = secret_fetcher.fetch_secret_string(&resend_secret_id).await.map_err(|e| format!("{e:#}"));
117
- match fetched.and_then(|json| secrets::extract_field(&json, "api_key")) {
118
- Ok(api_key) => unsafe { std::env::set_var("RESEND_API_KEY", api_key) },
119
- Err(e) => eprintln!("RESEND_SECRET_ID could not be read, so email sending stays off: {e}"),
120
- }
83
+ for extension in extension::installed() {
84
+ extension.boot_secrets(&secret_fetcher).await;
121
85
  }
122
86
  let wasm_path = PathBuf::from(
123
87
  std::env::var("HECKS_WASM_PATH").unwrap_or_else(|_| "banking.wasm".to_string()),
@@ -132,7 +96,9 @@ async fn main() -> Result<(), Error> {
132
96
  let schema = std::env::var("HECKS_SCHEMA").ok().filter(|s| !s.is_empty());
133
97
  // Checkout on AWS keeps the business's payment keys in a named Secrets
134
98
  // Manager secret; refuse before touching the database when none is named.
135
- payments::check_boot(web::checkout_enabled(std::env::var("HECKS_CHECKOUT_DOMAIN").ok().as_deref(), &domain))?;
99
+ for extension in extension::installed() {
100
+ extension.boot_check(&domain)?;
101
+ }
136
102
 
137
103
  // RDS refuses a plain NoTls connection by default and needs AWS's
138
104
  // own RDS CA specifically, not a generic public bundle (see
@@ -495,7 +495,7 @@ async fn table_exists<C: GenericClient>(client: &C, name: &str) -> anyhow::Resul
495
495
 
496
496
  /// No backfill: adopting an era means its history already exists elsewhere.
497
497
  /// Runs in its own transaction because `create_head_snapshot`'s SAVEPOINT needs one open.
498
- pub(crate) async fn adopt_head_snapshots<C: GenericClient>(
498
+ pub async fn adopt_head_snapshots<C: GenericClient>(
499
499
  client: &C,
500
500
  domain: &str,
501
501
  aggregates: &[Aggregate],
@@ -300,7 +300,7 @@ impl Tenant {
300
300
  async fn register(&self, slug: &str) -> (u64, Value) {
301
301
  let body = json!({"event_slug": slug, "name": "Ada Lovelace", "email": "ada@example.com"}).to_string();
302
302
  let platform = self.platform_now().await;
303
- let response = web::registrations_route(&body, &platform, &self.client, &self.wasm, &self.config, &NeverInvoker, &crate::ir::fixture_payments()).await;
303
+ let response = web::registrations_route(&body, &platform, &self.client, &self.wasm, &self.config, &NeverInvoker, &crate::commerce_ir::fixture_payments()).await;
304
304
  (response["statusCode"].as_u64().unwrap(), serde_json::from_str(response["body"].as_str().unwrap()).unwrap_or(Value::Null))
305
305
  }
306
306
 
@@ -314,14 +314,14 @@ impl Tenant {
314
314
  let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() as i64;
315
315
  let header = sign(secret, now, &payload);
316
316
  let platform = self.platform_now().await;
317
- let response = web::webhook_route(&payload, &header, &platform, &self.client, &self.wasm, &self.config, &NeverInvoker, &crate::ir::fixture_payments()).await;
317
+ let response = web::webhook_route(&payload, &header, &platform, &self.client, &self.wasm, &self.config, &NeverInvoker, &crate::commerce_ir::fixture_payments()).await;
318
318
  response["statusCode"].as_u64().unwrap()
319
319
  }
320
320
 
321
321
  // POST /registrations/:id/complete with a successful outcome (the mock
322
322
  // walkthrough's "Pay" button).
323
323
  async fn settle(&self, reference: &str) -> u64 {
324
- let response = web::registration_complete_route(reference, r#"{"outcome":"succeeded"}"#, &self.client, &self.wasm, &self.config, &NeverInvoker, &crate::ir::fixture_payments()).await;
324
+ let response = web::registration_complete_route(reference, r#"{"outcome":"succeeded"}"#, &self.client, &self.wasm, &self.config, &NeverInvoker, &crate::commerce_ir::fixture_payments()).await;
325
325
  response["statusCode"].as_u64().unwrap()
326
326
  }
327
327
 
@@ -672,7 +672,7 @@ async fn a_declined_payment_frees_the_seat_and_a_paid_one_keeps_it() {
672
672
 
673
673
  let (_, body) = t.register("declined").await;
674
674
  let first = body["registration_id"].as_str().unwrap().to_string();
675
- let response = web::registration_complete_route(&first, r#"{"outcome":"failed"}"#, &t.client, &t.wasm, &t.config, &NeverInvoker, &crate::ir::fixture_payments()).await;
675
+ let response = web::registration_complete_route(&first, r#"{"outcome":"failed"}"#, &t.client, &t.wasm, &t.config, &NeverInvoker, &crate::commerce_ir::fixture_payments()).await;
676
676
  assert_eq!(response["statusCode"].as_u64(), Some(200));
677
677
  assert_eq!(t.payment_status(&first).await, "failed");
678
678
 
@@ -714,7 +714,7 @@ async fn archiving_a_paid_registration_frees_its_seat_and_restoring_it_takes_the
714
714
  assert_eq!(registration_status(&t, &reference).await, "archived");
715
715
  assert_eq!(t.payment_status(&reference).await, "succeeded", "archiving never touches the Payment");
716
716
  let read = dispatch::read(&t.client, &t.wasm).await.unwrap();
717
- assert_eq!(web::seats_left(&read, "CheckoutFixture", &crate::ir::fixture_payments(), "archive-me"), Some(1));
717
+ assert_eq!(web::seats_left(&read, "CheckoutFixture", &crate::commerce_ir::fixture_payments(), "archive-me"), Some(1));
718
718
 
719
719
  let (status, body) = t.register("archive-me").await;
720
720
  assert_eq!(status, 200, "the archived registration's seat is free again: {body}");
@@ -728,11 +728,11 @@ async fn archiving_a_paid_registration_frees_its_seat_and_restoring_it_takes_the
728
728
  assert_eq!(registration_status(&t, &reference).await, "active");
729
729
  let read = dispatch::read(&t.client, &t.wasm).await.unwrap();
730
730
  assert_eq!(
731
- web::seats_taken(&read, "CheckoutFixture", &crate::ir::fixture_payments(), "archive-me"),
731
+ web::seats_taken(&read, "CheckoutFixture", &crate::commerce_ir::fixture_payments(), "archive-me"),
732
732
  2,
733
733
  "a restored registration counts again, even past capacity: the site refuses a restore with no seat"
734
734
  );
735
- assert_eq!(web::seats_left(&read, "CheckoutFixture", &crate::ir::fixture_payments(), "archive-me"), Some(0));
735
+ assert_eq!(web::seats_left(&read, "CheckoutFixture", &crate::commerce_ir::fixture_payments(), "archive-me"), Some(0));
736
736
  }
737
737
 
738
738
  #[tokio::test]
@@ -876,7 +876,7 @@ async fn an_event_verified_only_by_the_public_mock_secret_is_refused_for_a_real_
876
876
  let payload = event.to_string();
877
877
  let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() as i64;
878
878
  let header = sign(web::MOCK_STRIPE_WEBHOOK_SECRET, now, &payload);
879
- let response = web::webhook_route(&payload, &header, &t.platform, &t.client, &t.wasm, &t.config, &NeverInvoker, &crate::ir::fixture_payments()).await;
879
+ let response = web::webhook_route(&payload, &header, &t.platform, &t.client, &t.wasm, &t.config, &NeverInvoker, &crate::commerce_ir::fixture_payments()).await;
880
880
  assert_eq!(response["statusCode"], 500, "{event}");
881
881
  assert_eq!(t.payment_status(&reference).await, "pending", "a forged completion must not settle a real payment");
882
882
  }
@@ -187,7 +187,7 @@ impl Connection {
187
187
  /// This tenant's connection, read out of the replayed instances, or `None`
188
188
  /// until an Owner has connected an account.
189
189
  pub fn connection(read: &Value, domain: &str) -> Option<Connection> {
190
- let (_, state) = instances_for(read, &crate::ir::payment_connection_binding(domain).instance_prefix()).into_iter().find(|(id, _)| id == CONNECTION_SLUG)?;
190
+ let (_, state) = instances_for(read, &crate::commerce_ir::payment_connection_binding(domain).instance_prefix()).into_iter().find(|(id, _)| id == CONNECTION_SLUG)?;
191
191
  let text = |field: &str| state.get(field).and_then(|v| v.get("value")).and_then(|v| v.as_str()).unwrap_or_default().to_string();
192
192
  Some(Connection {
193
193
  status: state.get("status").and_then(|v| v.as_str()).unwrap_or_default().to_string(),
@@ -236,14 +236,14 @@ pub fn checkout_plan(connection: Option<&Connection>, platform: &PlatformConfig)
236
236
  }
237
237
 
238
238
  async fn dispatch_on_connection(
239
- which: crate::ir::ConnectionVerb,
239
+ which: crate::commerce_ir::ConnectionVerb,
240
240
  facts: Value,
241
241
  client: &Mutex<Client>,
242
242
  wasm_path: &Path,
243
243
  config: &LineageConfig,
244
244
  invoker: &dyn LambdaInvoker,
245
245
  ) -> anyhow::Result<dispatch::Outcome> {
246
- let verb = crate::ir::payment_connection_binding(&config.domain).verb(which).to_string();
246
+ let verb = crate::commerce_ir::payment_connection_binding(&config.domain).verb(which).to_string();
247
247
  dispatch::handle_routed(client, wasm_path, &verb, json!(CONNECTION_SLUG), facts, None, config, invoker).await
248
248
  }
249
249
 
@@ -362,8 +362,8 @@ pub async fn route(
362
362
  "/payments/connection" => show(&caller, platform, client, wasm_path, config).await,
363
363
  "/payments/connection/direct" => direct_route(&caller, raw_body, platform, client, wasm_path, config, invoker).await,
364
364
  "/payments/connection/disconnect" => disconnect_route(&caller, platform, client, wasm_path, config, invoker).await,
365
- "/payments/connection/enable" => switch_route(&caller, crate::ir::ConnectionVerb::Enable, platform, client, wasm_path, config, invoker).await,
366
- _ => switch_route(&caller, crate::ir::ConnectionVerb::Disable, platform, client, wasm_path, config, invoker).await,
365
+ "/payments/connection/enable" => switch_route(&caller, crate::commerce_ir::ConnectionVerb::Enable, platform, client, wasm_path, config, invoker).await,
366
+ _ => switch_route(&caller, crate::commerce_ir::ConnectionVerb::Disable, platform, client, wasm_path, config, invoker).await,
367
367
  })
368
368
  }
369
369
 
@@ -403,10 +403,10 @@ async fn record_connection(
403
403
  None => {
404
404
  let mut creation = facts;
405
405
  creation["slug"] = json!({"value": CONNECTION_SLUG});
406
- dispatch::handle_facts(client, wasm_path, &crate::ir::payment_connection_binding(&config.domain).connect, creation, None, config, invoker).await
406
+ dispatch::handle_facts(client, wasm_path, &crate::commerce_ir::payment_connection_binding(&config.domain).connect, creation, None, config, invoker).await
407
407
  }
408
- Some("disconnected") => dispatch_on_connection(crate::ir::ConnectionVerb::Reconnect, facts, client, wasm_path, config, invoker).await,
409
- Some("paused") => dispatch_on_connection(crate::ir::ConnectionVerb::Resume, facts, client, wasm_path, config, invoker).await,
408
+ Some("disconnected") => dispatch_on_connection(crate::commerce_ir::ConnectionVerb::Reconnect, facts, client, wasm_path, config, invoker).await,
409
+ Some("paused") => dispatch_on_connection(crate::commerce_ir::ConnectionVerb::Resume, facts, client, wasm_path, config, invoker).await,
410
410
  Some(_) => return json_error(409, "an account is already connected — disconnect it first"),
411
411
  };
412
412
  match outcome {
@@ -465,7 +465,7 @@ async fn disconnect_route(caller: &Caller, platform: &PlatformConfig, client: &M
465
465
  return json_error(409, "nothing to disconnect");
466
466
  };
467
467
 
468
- let which = if existing.status == "enabled" { crate::ir::ConnectionVerb::Suspend } else { crate::ir::ConnectionVerb::Disconnect };
468
+ let which = if existing.status == "enabled" { crate::commerce_ir::ConnectionVerb::Suspend } else { crate::commerce_ir::ConnectionVerb::Disconnect };
469
469
  match dispatch_on_connection(which, json!({}), client, wasm_path, config, invoker).await {
470
470
  Ok(outcome) if outcome.accepted => {
471
471
  if existing.is_direct() {
@@ -480,7 +480,7 @@ async fn disconnect_route(caller: &Caller, platform: &PlatformConfig, client: &M
480
480
  }
481
481
 
482
482
  // Enable or disable real payments: operator only.
483
- async fn switch_route(caller: &Caller, which: crate::ir::ConnectionVerb, platform: &PlatformConfig, client: &Mutex<Client>, wasm_path: &Path, config: &LineageConfig, invoker: &dyn LambdaInvoker) -> Value {
483
+ async fn switch_route(caller: &Caller, which: crate::commerce_ir::ConnectionVerb, platform: &PlatformConfig, client: &Mutex<Client>, wasm_path: &Path, config: &LineageConfig, invoker: &dyn LambdaInvoker) -> Value {
484
484
  if let Err(response) = require_operator(caller) {
485
485
  return response;
486
486
  }
@@ -4,6 +4,7 @@
4
4
  use axum::http::header::{HeaderValue, CONTENT_TYPE, RETRY_AFTER};
5
5
  use axum::http::{HeaderMap, HeaderName, StatusCode};
6
6
  use axum::response::{IntoResponse, Response};
7
+ use crate::extension::RateRule;
7
8
  use serde_json::json;
8
9
  use std::collections::{BTreeMap, HashMap, VecDeque};
9
10
  use std::net::{IpAddr, Ipv6Addr};
@@ -14,8 +15,6 @@ use std::time::{Duration, Instant};
14
15
  const UNKNOWN_CLIENT: &str = "unknown";
15
16
 
16
17
  const DEFAULT_WINDOW_SECONDS: u64 = 3600;
17
- const DEFAULT_SUBSCRIBE: usize = 10;
18
- const DEFAULT_REGISTER: usize = 15;
19
18
  const DEFAULT_MAX_KEYS: usize = 10_000;
20
19
 
21
20
  /// The outcome of counting one request against a window.
@@ -215,28 +214,12 @@ fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
215
214
  a.len() == b.len() && a.iter().zip(b).fold(0u8, |diff, (x, y)| diff | (x ^ y)) == 0
216
215
  }
217
216
 
218
- #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
219
- pub enum Form {
220
- Subscribe,
221
- Register,
222
- }
223
-
224
- impl Form {
225
- pub fn for_request(method: &str, path: &str) -> Option<Self> {
226
- match (method, path) {
227
- ("POST", "/newsletter/subscribers") => Some(Self::Subscribe),
228
- ("POST", "/registrations") => Some(Self::Register),
229
- _ => None,
230
- }
231
- }
232
- }
233
-
234
217
  #[derive(Debug, Clone, PartialEq, Eq)]
235
218
  pub struct Config {
236
219
  enabled: bool,
237
220
  window: Duration,
238
- subscribe: usize,
239
- register: usize,
221
+ // Each extension rule's budget, by rule name.
222
+ limits: HashMap<&'static str, usize>,
240
223
  max_keys: usize,
241
224
  trust: ProxyTrust,
242
225
  }
@@ -256,8 +239,10 @@ impl Config {
256
239
  }
257
240
  };
258
241
  let window = number("HECKS_RATE_LIMIT_WINDOW_SECONDS", DEFAULT_WINDOW_SECONDS);
259
- let subscribe = number("HECKS_RATE_LIMIT_SUBSCRIBE", DEFAULT_SUBSCRIBE as u64);
260
- let register = number("HECKS_RATE_LIMIT_REGISTER", DEFAULT_REGISTER as u64);
242
+ let limits = crate::extension::rate_rules()
243
+ .iter()
244
+ .map(|rule| (rule.name, usize::try_from(number(rule.limit_env, rule.default_limit as u64)).unwrap_or(usize::MAX)))
245
+ .collect();
261
246
  let max_keys = number("HECKS_RATE_LIMIT_MAX_KEYS", DEFAULT_MAX_KEYS as u64);
262
247
  let hops = get("HECKS_TRUSTED_PROXY_HOPS").map(|v| v.trim().to_string()).filter(|v| !v.is_empty()).map_or(0, |raw| {
263
248
  raw.parse::<usize>().unwrap_or_else(|_| {
@@ -272,19 +257,20 @@ impl Config {
272
257
  let config = Self {
273
258
  enabled,
274
259
  window: Duration::from_secs(window),
275
- subscribe: usize::try_from(subscribe).unwrap_or(usize::MAX),
276
- register: usize::try_from(register).unwrap_or(usize::MAX),
260
+ limits,
277
261
  max_keys: usize::try_from(max_keys).unwrap_or(usize::MAX),
278
262
  trust: ProxyTrust { networks, hops, auth },
279
263
  };
280
264
  (config, warnings)
281
265
  }
282
266
 
283
- fn limit_for(&self, form: Form) -> usize {
284
- match form {
285
- Form::Subscribe => self.subscribe,
286
- Form::Register => self.register,
287
- }
267
+ fn limit_for(&self, rule: &RateRule) -> usize {
268
+ self.limits.get(rule.name).copied().unwrap_or(rule.default_limit)
269
+ }
270
+
271
+ #[cfg(test)]
272
+ fn limit(&self, name: &str) -> usize {
273
+ self.limits[name]
288
274
  }
289
275
  }
290
276
 
@@ -336,7 +322,7 @@ pub enum Verdict {
336
322
 
337
323
  pub struct RateLimits {
338
324
  config: Config,
339
- windows: Mutex<HashMap<Form, SlidingWindow>>,
325
+ windows: Mutex<HashMap<&'static str, SlidingWindow>>,
340
326
  warned_untrusted_proxy: AtomicBool,
341
327
  }
342
328
 
@@ -355,8 +341,7 @@ impl RateLimits {
355
341
  json!({
356
342
  "enabled": config.enabled,
357
343
  "window_seconds": config.window.as_secs(),
358
- "subscribe": config.subscribe,
359
- "register": config.register,
344
+ "limits": config.limits,
360
345
  "trusted_networks": config.trust.networks.len(),
361
346
  "trusted_proxy_hops": config.trust.hops,
362
347
  "proxy_auth": config.trust.auth.is_some(),
@@ -372,7 +357,7 @@ impl RateLimits {
372
357
 
373
358
  // `check` with the clock supplied, so tests need not sleep.
374
359
  pub fn check_at(&self, method: &str, path: &str, headers: &HeaderMap, peer: Option<IpAddr>, now: Instant) -> Verdict {
375
- let Some(form) = Form::for_request(method, path).filter(|_| self.config.enabled) else {
360
+ let Some(rule) = crate::extension::rate_rule_for(method, path).filter(|_| self.config.enabled) else {
376
361
  return Verdict::Allowed;
377
362
  };
378
363
  let key = self.client_key(headers, peer);
@@ -380,8 +365,8 @@ impl RateLimits {
380
365
  let mut windows = self.windows.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
381
366
  let config = &self.config;
382
367
  windows
383
- .entry(form)
384
- .or_insert_with(|| SlidingWindow::new(config.limit_for(form), config.window, config.max_keys))
368
+ .entry(rule.name)
369
+ .or_insert_with(|| SlidingWindow::new(config.limit_for(&rule), config.window, config.max_keys))
385
370
  .check(&key, now)
386
371
  };
387
372
  if decision.allowed {
@@ -725,7 +710,7 @@ mod tests {
725
710
  assert!(warnings.is_empty());
726
711
  assert!(config.enabled);
727
712
  assert_eq!(config.window, HOUR);
728
- assert_eq!((config.subscribe, config.register, config.max_keys), (10, 15, 10_000));
713
+ assert_eq!((config.limit("subscribe"), config.limit("register"), config.max_keys), (10, 15, 10_000));
729
714
  assert_eq!(config.trust, ProxyTrust::default());
730
715
  }
731
716
 
@@ -743,7 +728,7 @@ mod tests {
743
728
  ]);
744
729
  assert!(warnings.is_empty(), "{warnings:?}");
745
730
  assert_eq!(config.window, Duration::from_secs(60));
746
- assert_eq!((config.subscribe, config.register, config.max_keys), (3, 4, 50));
731
+ assert_eq!((config.limit("subscribe"), config.limit("register"), config.max_keys), (3, 4, 50));
747
732
  assert_eq!(config.trust, trust(&["10.0.0.0/8", "127.0.0.1"], 1, Some(("x-origin-secret", "s3cret"))));
748
733
  }
749
734
 
@@ -766,7 +751,7 @@ mod tests {
766
751
  ("HECKS_TRUSTED_PROXY_HOPS", "two"),
767
752
  ("HECKS_TRUSTED_PROXIES", "10.0.0.0/8, banana"),
768
753
  ]);
769
- assert_eq!((config.subscribe, config.register, config.window), (10, 15, HOUR));
754
+ assert_eq!((config.limit("subscribe"), config.limit("register"), config.window), (10, 15, HOUR));
770
755
  assert_eq!(config.trust.hops, 0);
771
756
  assert_eq!(config.trust.networks.len(), 1);
772
757
  assert_eq!(warnings.len(), 5, "{warnings:?}");
@@ -786,8 +771,9 @@ mod tests {
786
771
 
787
772
  #[test]
788
773
  fn only_the_public_write_routes_are_limited() {
789
- assert_eq!(Form::for_request("POST", "/newsletter/subscribers"), Some(Form::Subscribe));
790
- assert_eq!(Form::for_request("POST", "/registrations"), Some(Form::Register));
774
+ let name = |method, path| crate::extension::rate_rule_for(method, path).map(|rule| rule.name);
775
+ assert_eq!(name("POST", "/newsletter/subscribers"), Some("subscribe"));
776
+ assert_eq!(name("POST", "/registrations"), Some("register"));
791
777
  for (method, path) in [
792
778
  ("GET", "/newsletter/subscribers"),
793
779
  ("GET", "/newsletter/subscribers/confirm"),
@@ -800,7 +786,7 @@ mod tests {
800
786
  ("POST", "/members"),
801
787
  ("POST", "/newsletter/subscribers/"),
802
788
  ] {
803
- assert_eq!(Form::for_request(method, path), None, "{method} {path}");
789
+ assert_eq!(crate::extension::rate_rule_for(method, path).map(|rule| rule.name), None, "{method} {path}");
804
790
  }
805
791
  }
806
792
 
@@ -75,10 +75,54 @@ pub fn extract_field(secret_json: &str, field: &str) -> Result<String, String> {
75
75
  .ok_or_else(|| format!("secret's SecretString has no string {field:?} field"))
76
76
  }
77
77
 
78
+ /// The `postgres://` URL for a database secret.
79
+ ///
80
+ /// The user is the secret's `username`, which is `postgres` for RDS's managed master secret and a
81
+ /// client's own role for a database shared with other clients; a secret without one is read as
82
+ /// `postgres`. The port is the secret's `port` when it has one, else 5432. The password goes in
83
+ /// literal, as before: the URL parser downstream never percent-decodes it, so a client's generated
84
+ /// password is letters and digits only.
85
+ pub fn database_url(secret_json: &str, host: &str, database: &str) -> Result<String, String> {
86
+ let password = extract_field(secret_json, "password")?;
87
+ let user = extract_field(secret_json, "username").unwrap_or_else(|_| "postgres".to_string());
88
+ let port = serde_json::from_str::<serde_json::Value>(secret_json)
89
+ .ok()
90
+ .and_then(|v| v.get("port").and_then(|p| p.as_u64().or_else(|| p.as_str().and_then(|s| s.parse().ok()))))
91
+ .unwrap_or(5432);
92
+ Ok(format!("postgres://{user}:{password}@{host}:{port}/{database}"))
93
+ }
94
+
78
95
  #[cfg(test)]
79
96
  mod tests {
80
97
  use super::*;
81
98
 
99
+ #[test]
100
+ fn a_master_secret_logs_in_as_postgres_on_the_default_port() {
101
+ let json = r#"{"username":"postgres","password":"abc123"}"#;
102
+ assert_eq!(database_url(json, "db.example", "app").unwrap(), "postgres://postgres:abc123@db.example:5432/app");
103
+ }
104
+
105
+ #[test]
106
+ fn a_clients_own_secret_logs_in_as_its_role() {
107
+ let json = r#"{"username":"acme","password":"s3cr3t","host":"db.example","port":5433,"dbname":"acme"}"#;
108
+ assert_eq!(database_url(json, "db.example", "acme").unwrap(), "postgres://acme:s3cr3t@db.example:5433/acme");
109
+ }
110
+
111
+ #[test]
112
+ fn a_secret_with_no_username_is_read_as_postgres() {
113
+ assert_eq!(database_url(r#"{"password":"pw"}"#, "h", "d").unwrap(), "postgres://postgres:pw@h:5432/d");
114
+ }
115
+
116
+ #[test]
117
+ fn a_port_written_as_text_is_read() {
118
+ assert_eq!(database_url(r#"{"password":"pw","port":"6000"}"#, "h", "d").unwrap(), "postgres://postgres:pw@h:6000/d");
119
+ }
120
+
121
+ #[test]
122
+ fn a_secret_with_no_password_is_refused() {
123
+ assert!(database_url(r#"{"username":"acme"}"#, "h", "d").is_err());
124
+ }
125
+
82
126
  #[test]
83
127
  fn extracts_the_named_field_from_the_generated_secret_shape() {
84
128
  assert_eq!(
@@ -276,7 +276,8 @@ fn value_to_response(value: Value) -> Response {
276
276
  let Some(status_code) = value.get("statusCode").and_then(|v| v.as_u64()) else {
277
277
  return (StatusCode::OK, axum::Json(value)).into_response();
278
278
  };
279
- let status = StatusCode::from_u16(status_code as u16).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
279
+ // `try_from`, not `as u16`: 65736 would wrap to 200 and turn a handler's garbage into a success.
280
+ let status = u16::try_from(status_code).ok().and_then(|code| StatusCode::from_u16(code).ok()).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
280
281
 
281
282
  let mut headers = HeaderMap::new();
282
283
  if let Some(header_obj) = value.get("headers").and_then(|h| h.as_object()) {
@@ -665,3 +666,7 @@ mod tests {
665
666
  assert_eq!(version_body("e", "h", Some(""))["build"], env!("CARGO_PKG_VERSION"));
666
667
  }
667
668
  }
669
+
670
+ #[cfg(test)]
671
+ #[path = "boundary_fuzz/server.rs"]
672
+ mod boundary_fuzz;
@@ -0,0 +1,113 @@
1
+ // Commerce as a host extension: the newsletter, payments, registrations and mail routes, their boot
2
+ // check and their boot-time secret, answered through `extension::HostExtension` instead of being
3
+ // named in `render` and `route`.
4
+
5
+ use super::{
6
+ checkout_enabled, newsletter, newsletter_send, payments_routes, registrations_list_route, resend_webhook, respond, session_secret,
7
+ };
8
+ use crate::extension::{Ctx, HostExtension, RateRule};
9
+ use crate::payments;
10
+ use crate::secrets::{self, AwsSecretFetcher};
11
+ use async_trait::async_trait;
12
+ use serde_json::Value;
13
+
14
+ pub struct Commerce;
15
+
16
+ fn checkout_domain() -> Option<String> {
17
+ std::env::var("HECKS_CHECKOUT_DOMAIN").ok()
18
+ }
19
+
20
+ #[async_trait]
21
+ impl HostExtension for Commerce {
22
+ // Registration/checkout/newsletter routes are opt-in: they only exist when HECKS_CHECKOUT_DOMAIN
23
+ // names this domain, since Event/Registration and the Payments::Payment chapter are declared
24
+ // against it specifically. The capability shapes are pinned by
25
+ // spec/fixtures/rust_host/checkout_fixture, which this module's tests run against.
26
+ async fn guest_route(&self, ctx: &Ctx<'_>) -> Option<Value> {
27
+ if !checkout_enabled(checkout_domain().as_deref(), &ctx.config.domain) {
28
+ return None;
29
+ }
30
+ let ir = crate::extension::loaded_ir();
31
+ // Newsletter subscribe shares this gate (same hecksagon as the registration aggregates) and
32
+ // is checked first since it needs none of the Payments::Payment/Event context checkout
33
+ // requires. The subscriber list is PII (email, names, status): only an Admin or Owner
34
+ // holding the account cookie may read it, like sending does.
35
+ if ctx.method == "GET" && ctx.path == "/newsletter/subscribers" && ir.and_then(crate::commerce_ir::newsletter_provider).is_some() {
36
+ let Some(domain_ir) = ir else {
37
+ return Some(respond(500, "text/plain", "HECKS_IR_PATH not set or unreadable — this domain has no web layer configured"));
38
+ };
39
+ if let Err(response) = newsletter_send::require_admin(domain_ir, ctx.cookies, &session_secret(), ctx.client).await {
40
+ return Some(response);
41
+ }
42
+ }
43
+ let headers = ctx.event.and_then(|e| e.get("headers"));
44
+ if let Some(response) =
45
+ resend_webhook::resend_webhook_route(ctx.method, ctx.path, ctx.raw_body, headers, ctx.client, ctx.wasm_path, ctx.config, ctx.invoker).await
46
+ {
47
+ return Some(response);
48
+ }
49
+ if let Some(response) =
50
+ newsletter::newsletter_route(ctx.method, ctx.path, ctx.query, ctx.raw_body, ctx.client, ctx.wasm_path, ctx.config, ctx.invoker).await
51
+ {
52
+ return Some(response);
53
+ }
54
+ let stripe_signature = ctx
55
+ .event
56
+ .and_then(|e| e.get("headers"))
57
+ .and_then(|h| h.get("stripe-signature"))
58
+ .and_then(|v| v.as_str())
59
+ .unwrap_or("");
60
+ payments_routes(ir, ctx.method, ctx.path, ctx.raw_body, stripe_signature, ctx.client, ctx.wasm_path, ctx.config, ctx.invoker).await
61
+ }
62
+
63
+ async fn account_route(&self, ctx: &Ctx<'_>) -> Option<Value> {
64
+ let domain_ir = ctx.domain_ir?;
65
+ let secret = session_secret();
66
+ // Sending a newsletter issue (web/newsletter_send.rs's own header): an Admin's or Owner's
67
+ // account cookie, unlike the guest newsletter routes served ahead of the account gate.
68
+ if newsletter_send::issue_action(ctx.method, ctx.path).is_some() {
69
+ return newsletter_send::issue_route(
70
+ ctx.method, ctx.path, domain_ir, ctx.raw_body, ctx.cookies, &secret, ctx.client, ctx.wasm_path, ctx.config, ctx.invoker,
71
+ )
72
+ .await;
73
+ }
74
+ if ctx.method == "GET" && ctx.path == "/registrations" {
75
+ return Some(registrations_list_route(domain_ir, ctx.cookies, &secret, ctx.client, ctx.wasm_path, ctx.config).await);
76
+ }
77
+ // The tenant's payment connection: same gate as the checkout routes, since only the
78
+ // HECKS_CHECKOUT_DOMAIN domain carries one — any other domain served by this binary falls
79
+ // through as unknown.
80
+ if payments::owns(ctx.method, ctx.path) && checkout_enabled(checkout_domain().as_deref(), &ctx.config.domain) {
81
+ return payments::route(
82
+ ctx.method, ctx.path, ctx.raw_body, ctx.cookies, &secret, domain_ir, &payments::PlatformConfig::load().await,
83
+ ctx.client, ctx.wasm_path, ctx.config, ctx.invoker,
84
+ )
85
+ .await;
86
+ }
87
+ None
88
+ }
89
+
90
+ fn rate_rules(&self) -> Vec<RateRule> {
91
+ vec![
92
+ RateRule { name: "subscribe", method: "POST", path: "/newsletter/subscribers", limit_env: "HECKS_RATE_LIMIT_SUBSCRIBE", default_limit: 10 },
93
+ RateRule { name: "register", method: "POST", path: "/registrations", limit_env: "HECKS_RATE_LIMIT_REGISTER", default_limit: 15 },
94
+ ]
95
+ }
96
+
97
+ // Checkout on AWS keeps the business's payment keys in a named Secrets Manager secret; refuse
98
+ // before touching the database when none is named.
99
+ fn boot_check(&self, domain: &str) -> Result<(), String> {
100
+ payments::check_boot(checkout_enabled(checkout_domain().as_deref(), domain))
101
+ }
102
+
103
+ // RESEND_SECRET_ID: a failed fetch only logs -- mail is optional, and a missing secret must not
104
+ // stop the host from serving everything else (resend.rs answers 503 without a key).
105
+ async fn boot_secrets(&self, fetcher: &AwsSecretFetcher) {
106
+ let Ok(resend_secret_id) = std::env::var("RESEND_SECRET_ID") else { return };
107
+ let fetched = fetcher.fetch_secret_string(&resend_secret_id).await.map_err(|e| format!("{e:#}"));
108
+ match fetched.and_then(|json| secrets::extract_field(&json, "api_key")) {
109
+ Ok(api_key) => unsafe { std::env::set_var("RESEND_API_KEY", api_key) },
110
+ Err(e) => eprintln!("RESEND_SECRET_ID could not be read, so email sending stays off: {e}"),
111
+ }
112
+ }
113
+ }
@@ -2,7 +2,8 @@ use super::newsletter_send::{site_url, unsubscribe_token, unsubscribe_token_matc
2
2
  use super::{instances_for, last_refusal, respond};
3
3
  use crate::auth;
4
4
  use crate::dispatch;
5
- use crate::ir::{ir, newsletter_provider, NewsletterProvider};
5
+ use crate::ir::ir;
6
+ use crate::commerce_ir::{newsletter_provider, NewsletterProvider};
6
7
  use crate::journal::LineageConfig;
7
8
  use crate::lambda_client::LambdaInvoker;
8
9
  use crate::resend::{Email, Mailer};
@@ -1,7 +1,7 @@
1
1
  use super::{active_session_email, instances_for, last_refusal, percent_decode, respond};
2
2
  use crate::auth;
3
3
  use crate::dispatch;
4
- use crate::ir::{newsletter_issues_provider, newsletter_provider, NewsletterIssuesProvider, NewsletterProvider};
4
+ use crate::commerce_ir::{newsletter_issues_provider, newsletter_provider, NewsletterIssuesProvider, NewsletterProvider};
5
5
  use crate::journal::LineageConfig;
6
6
  use crate::lambda_client::LambdaInvoker;
7
7
  use crate::resend::{Email, Mailer};
@@ -4,7 +4,7 @@
4
4
 
5
5
  use super::instances_for;
6
6
  use crate::dispatch;
7
- use crate::ir::PaymentsProvider;
7
+ use crate::commerce_ir::PaymentsProvider;
8
8
  use crate::journal::LineageConfig;
9
9
  use crate::resend::{Email, Mailer};
10
10
  use serde_json::{json, Value};
@@ -82,7 +82,7 @@ pub(super) async fn send_receipt(reference: &str, client: &Mutex<Client>, wasm_p
82
82
  let Ok(read) = dispatch::read(client, wasm_path).await else {
83
83
  return;
84
84
  };
85
- let binding = crate::ir::registrations_binding(&config.domain);
85
+ let binding = crate::commerce_ir::registrations_binding(&config.domain);
86
86
  let registrations = instances_for(&read, &binding.registration_prefix());
87
87
  let Some((_, registration)) = registrations.iter().find(|(id, _)| id == reference) else {
88
88
  return;