hecks 3.7.0 → 3.9.1

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 (46) 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.rb +4 -2
  4. data/lib/hecks/corpus/routes.rb +1 -1
  5. data/lib/hecks/gate/stages.yml +11 -1
  6. data/lib/hecks/hecks/adapters/site_toolchain/prober.rb +64 -0
  7. data/lib/hecks/hecks/adapters/site_toolchain.rb +41 -5
  8. data/lib/hecks/language/bluebook/vocabulary.bluebook +1 -1
  9. data/lib/hecks/projections/deploy/shared_database_instance/templates/rds.yaml.tmpl +1 -1
  10. data/lib/hecks/projections/deploy/templates/lambda/template.yaml.tmpl +1 -1
  11. data/lib/hecks/projections/site/probe.rb +131 -0
  12. data/lib/hecks/projections/site/site_host/dockerfile.rb +1 -1
  13. data/lib/hecks/runtime/value/validation.rb +34 -3
  14. data/lib/hecks/site/bluebook/site.bluebook +31 -0
  15. data/lib/hecks/site/bluebook/site.ports.hecksagon +6 -0
  16. data/lib/hecks/version.rb +1 -1
  17. data/lib/hecks/vocabulary.rb +1 -1
  18. data/prebuilt/{verdicts-be73f0df9eee0abe2f4aadc968321c4b919d19d424a3ccfe1777a71dabf7d9e7.json → verdicts-91aaf93ce8981bd9a754948f075d95650dc2ec6435015376f5c60992b36a24b1.json} +1 -1
  19. data/rust/codegen/src/fielded.rs +3 -3
  20. data/rust/codegen/src/json_codec.rs +2 -2
  21. data/rust/codegen/src/naming.rs +20 -0
  22. data/rust/host/Cargo.toml +4 -0
  23. data/rust/host/HECKS_RELEASE +1 -1
  24. data/rust/host/src/boot.rs +443 -0
  25. data/rust/host/src/boundary_fuzz/ir.rs +1 -0
  26. data/rust/host/src/commerce_ir.rs +459 -0
  27. data/rust/host/src/extension.rs +100 -0
  28. data/rust/host/src/ir.rs +0 -448
  29. data/rust/host/src/journal.rs +1 -1
  30. data/rust/host/src/lib.rs +40 -0
  31. data/rust/host/src/main.rs +4 -477
  32. data/rust/host/src/mint.rs +1 -1
  33. data/rust/host/src/payments/tests.rs +8 -8
  34. data/rust/host/src/payments.rs +10 -10
  35. data/rust/host/src/rate_limit.rs +27 -41
  36. data/rust/host/src/secrets.rs +15 -0
  37. data/rust/host/src/web/commerce.rs +121 -0
  38. data/rust/host/src/web/newsletter.rs +2 -1
  39. data/rust/host/src/web/newsletter_send.rs +1 -1
  40. data/rust/host/src/web/registration_receipt.rs +2 -2
  41. data/rust/host/src/web/registrations.rs +49 -49
  42. data/rust/host/src/web/resend_webhook.rs +300 -0
  43. data/rust/host/src/web.rs +43 -47
  44. metadata +13 -5
  45. /data/prebuilt/{a0818e2de67364dc1ab22868b6cbf869c62da6f8d9b924c778fa5cce2e76ee94.marshal → 0ee7f83cba59ce7d1c45c65ba166b071779f70143449f584f33ff7989f64eccb.marshal} +0 -0
  46. /data/prebuilt/{945481eefbdb584649d45f256307053558966a868b9c38dee7ee88f8169610f8.marshal → 6e4a6bcf0aa880e2e6b056e1846b70e5a768090b9dc998bbadeaf5d6bcfd3db8.marshal} +0 -0
@@ -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,6 +75,12 @@ 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
+ /// One named string field out of a secret's JSON `SecretString`, or `None` when the secret does not
79
+ /// carry it (or is not JSON, or the field is blank). For a field a secret may have and may not.
80
+ pub fn optional_field(secret_json: &str, field: &str) -> Option<String> {
81
+ extract_field(secret_json, field).ok().filter(|value| !value.trim().is_empty())
82
+ }
83
+
78
84
  /// The `postgres://` URL for a database secret.
79
85
  ///
80
86
  /// The user is the secret's `username`, which is `postgres` for RDS's managed master secret and a
@@ -96,6 +102,15 @@ pub fn database_url(secret_json: &str, host: &str, database: &str) -> Result<Str
96
102
  mod tests {
97
103
  use super::*;
98
104
 
105
+ #[test]
106
+ fn an_optional_field_is_read_when_present_and_none_when_absent_blank_or_not_json() {
107
+ let json = r#"{"api_key":"re_1","webhook_secret":"whsec_abc","blank":" "}"#;
108
+ assert_eq!(optional_field(json, "webhook_secret").as_deref(), Some("whsec_abc"));
109
+ assert_eq!(optional_field(json, "missing"), None);
110
+ assert_eq!(optional_field(json, "blank"), None);
111
+ assert_eq!(optional_field("not json", "webhook_secret"), None);
112
+ }
113
+
99
114
  #[test]
100
115
  fn a_master_secret_logs_in_as_postgres_on_the_default_port() {
101
116
  let json = r#"{"username":"postgres","password":"abc123"}"#;
@@ -0,0 +1,121 @@
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). The same
105
+ // secret may also carry `webhook_secret`, the signing secret of the Resend webhook that
106
+ // POST /webhooks/resend verifies; without it that route answers 503.
107
+ async fn boot_secrets(&self, fetcher: &AwsSecretFetcher) {
108
+ let Ok(resend_secret_id) = std::env::var("RESEND_SECRET_ID") else { return };
109
+ let fetched = fetcher.fetch_secret_string(&resend_secret_id).await.map_err(|e| format!("{e:#}"));
110
+ let read = fetched.and_then(|json| secrets::extract_field(&json, "api_key").map(|api_key| (api_key, secrets::optional_field(&json, "webhook_secret"))));
111
+ match read {
112
+ Ok((api_key, webhook_secret)) => unsafe {
113
+ std::env::set_var("RESEND_API_KEY", api_key);
114
+ if let Some(webhook_secret) = webhook_secret {
115
+ std::env::set_var("RESEND_WEBHOOK_SECRET", webhook_secret);
116
+ }
117
+ },
118
+ Err(e) => eprintln!("RESEND_SECRET_ID could not be read, so email sending stays off: {e}"),
119
+ }
120
+ }
121
+ }
@@ -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;