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
@@ -0,0 +1,300 @@
1
+ use super::{instances_for, respond};
2
+ use crate::dispatch;
3
+ use crate::commerce_ir::newsletter_issues_provider;
4
+ use crate::extension::loaded_ir;
5
+ use crate::journal::LineageConfig;
6
+ use crate::lambda_client::LambdaInvoker;
7
+ use hmac::{Hmac, Mac};
8
+ use serde_json::{json, Value};
9
+ use sha2::Sha256;
10
+ use std::path::Path;
11
+ use tokio::sync::Mutex;
12
+ use tokio_postgres::Client;
13
+
14
+ type HmacSha256 = Hmac<Sha256>;
15
+
16
+ // POST /webhooks/resend: Resend reports that a delivered issue was opened or
17
+ // clicked. The first open and the first click of each delivery are recorded on
18
+ // its Delivery (`RecordOpen` / `RecordClick`, which refuse a repeat). The
19
+ // delivery is found by the Resend message id stored when the issue was sent.
20
+ // Served under the same gate as the other newsletter routes, and only when
21
+ // RESEND_WEBHOOK_SECRET (the endpoint's `whsec_` signing secret) is set, either
22
+ // directly or from the `webhook_secret` field of the secret RESEND_SECRET_ID names.
23
+
24
+ const SECRET_VARIABLE: &str = "RESEND_WEBHOOK_SECRET";
25
+ // Resend signs with Svix, whose default replay window is five minutes.
26
+ const TOLERANCE_SECONDS: i64 = 300;
27
+
28
+ #[derive(Debug, PartialEq)]
29
+ enum Tracked {
30
+ Open,
31
+ Click,
32
+ }
33
+
34
+ impl Tracked {
35
+ fn command_suffix(&self) -> &'static str {
36
+ match self {
37
+ Tracked::Open => "RecordOpen",
38
+ Tracked::Click => "RecordClick",
39
+ }
40
+ }
41
+ }
42
+
43
+ #[allow(clippy::too_many_arguments)]
44
+ pub(super) async fn resend_webhook_route(
45
+ method: &str,
46
+ path: &str,
47
+ raw_body: &str,
48
+ headers: Option<&Value>,
49
+ client: &Mutex<Client>,
50
+ wasm_path: &Path,
51
+ config: &LineageConfig,
52
+ invoker: &dyn LambdaInvoker,
53
+ ) -> Option<Value> {
54
+ if method != "POST" || path != "/webhooks/resend" {
55
+ return None;
56
+ }
57
+ let header = |name: &str| headers.and_then(|h| h.get(name)).and_then(|v| v.as_str()).unwrap_or("");
58
+ let secret = std::env::var(SECRET_VARIABLE).unwrap_or_default();
59
+ if secret.trim().is_empty() {
60
+ return Some(respond(503, "text/plain", &format!("{SECRET_VARIABLE} is not set, so Resend events cannot be verified")));
61
+ }
62
+ if let Err(reason) = verify(raw_body, header("svix-id"), header("svix-timestamp"), header("svix-signature"), secret.trim(), unix_now()) {
63
+ return Some(respond(400, "text/plain", &reason));
64
+ }
65
+ let event: Value = match serde_json::from_str(raw_body) {
66
+ Ok(v) => v,
67
+ Err(_) => return Some(respond(400, "text/plain", "invalid JSON")),
68
+ };
69
+ let Some((tracked, message_id)) = tracked_event(&event) else {
70
+ return Some(respond(200, "text/plain", ""));
71
+ };
72
+ let Some(provider) = loaded_ir().and_then(newsletter_issues_provider) else {
73
+ return Some(respond(200, "text/plain", ""));
74
+ };
75
+ // `record_delivery` names `<Chapter>::Delivery.Record`; the open and click
76
+ // commands sit on the same aggregate.
77
+ let Some(delivery_aggregate) = provider.record_delivery.rsplit_once('.').map(|(aggregate, _)| aggregate.to_string()) else {
78
+ return Some(respond(200, "text/plain", ""));
79
+ };
80
+ let read = match dispatch::read(client, wasm_path).await {
81
+ Ok(r) => r,
82
+ Err(e) => return Some(respond(500, "text/plain", &format!("{e:#}"))),
83
+ };
84
+ let Some(delivery_id) = delivery_for(&instances_for(&read, &format!("{delivery_aggregate}#")), &message_id) else {
85
+ // A message this site did not send as a newsletter (a receipt, a
86
+ // confirmation): nothing to record, and a 200 stops Resend's retries.
87
+ return Some(respond(200, "text/plain", ""));
88
+ };
89
+ let occurred_at = json!({ "occurred_at": { "value": occurred_at(&event) } });
90
+ let verb = format!("{delivery_aggregate}.{}", tracked.command_suffix());
91
+ // A refusal is a repeat (the first open or click is already recorded):
92
+ // benign, and 200 tells Resend not to retry.
93
+ match dispatch::handle_routed(client, wasm_path, &verb, json!(delivery_id), occurred_at, None, config, invoker).await {
94
+ Err(e) => Some(respond(500, "text/plain", &format!("{e:#}"))),
95
+ Ok(_) => Some(respond(200, "text/plain", "")),
96
+ }
97
+ }
98
+
99
+ fn unix_now() -> i64 {
100
+ std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0)
101
+ }
102
+
103
+ // What the event is and which sent message it is about; `None` for any event
104
+ // type nothing here records.
105
+ fn tracked_event(event: &Value) -> Option<(Tracked, String)> {
106
+ let tracked = match event.get("type")?.as_str()? {
107
+ "email.opened" => Tracked::Open,
108
+ "email.clicked" => Tracked::Click,
109
+ _ => return None,
110
+ };
111
+ let message_id = event.get("data")?.get("email_id")?.as_str()?.to_string();
112
+ Some((tracked, message_id))
113
+ }
114
+
115
+ // The event's own time in seconds; the arrival time when it carries none.
116
+ fn occurred_at(event: &Value) -> i64 {
117
+ event
118
+ .get("created_at")
119
+ .and_then(|v| v.as_str())
120
+ .and_then(parse_rfc3339_seconds)
121
+ .unwrap_or_else(unix_now)
122
+ }
123
+
124
+ // `2026-10-06T21:59:20.123Z` or `2026-10-06T21:59:20+00:00` as unix seconds.
125
+ fn parse_rfc3339_seconds(text: &str) -> Option<i64> {
126
+ let (date, time) = text.split_once('T')?;
127
+ let mut ymd = date.split('-').map(|p| p.parse::<i64>().ok());
128
+ let (year, month, day) = (ymd.next()??, ymd.next()??, ymd.next()??);
129
+ let clock: String = time.chars().take_while(|c| c.is_ascii_digit() || *c == ':').collect();
130
+ let mut hms = clock.split(':').map(|p| p.parse::<i64>().ok());
131
+ let (hour, minute, second) = (hms.next()??, hms.next()??, hms.next().flatten().unwrap_or(0));
132
+ // Days since 1970-01-01 (Howard Hinnant's civil-from-days, inverted).
133
+ let year = if month <= 2 { year - 1 } else { year };
134
+ let era = year.div_euclid(400);
135
+ let year_of_era = year - era * 400;
136
+ let shifted_month = (month + 9) % 12;
137
+ let day_of_year = (153 * shifted_month + 2) / 5 + day - 1;
138
+ let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
139
+ let days = era * 146097 + day_of_era - 719468;
140
+ Some(days * 86400 + hour * 3600 + minute * 60 + second)
141
+ }
142
+
143
+ // The id of the delivery whose stored Resend message id is `message_id`.
144
+ fn delivery_for(deliveries: &[(String, Value)], message_id: &str) -> Option<String> {
145
+ deliveries
146
+ .iter()
147
+ .find(|(_, state)| state.get("message_id").and_then(|m| m.get("value")).and_then(|v| v.as_str()) == Some(message_id))
148
+ .map(|(id, _)| id.clone())
149
+ }
150
+
151
+ // Svix signs `<id>.<timestamp>.<raw body>` with the key inside the `whsec_`
152
+ // secret, base64 (`v1,<signature>`, several space-separated while a secret is
153
+ // being rotated). `now` is a parameter so a test can hold time fixed.
154
+ fn verify(payload: &str, id: &str, timestamp: &str, signatures: &str, secret: &str, now: i64) -> Result<(), String> {
155
+ if id.is_empty() || timestamp.is_empty() || signatures.is_empty() {
156
+ return Err("missing svix-id, svix-timestamp or svix-signature header".to_string());
157
+ }
158
+ let sent: i64 = timestamp.parse().map_err(|_| "svix-timestamp is not a number".to_string())?;
159
+ if (now - sent).abs() > TOLERANCE_SECONDS {
160
+ return Err(format!("timestamp {sent} is outside the {TOLERANCE_SECONDS}s tolerance (now: {now})"));
161
+ }
162
+ let key = base64_decode(secret.strip_prefix("whsec_").unwrap_or(secret)).ok_or_else(|| "webhook secret is not valid base64".to_string())?;
163
+ let mut mac = HmacSha256::new_from_slice(&key).map_err(|e| format!("webhook secret unusable as an HMAC key: {e}"))?;
164
+ mac.update(format!("{id}.{timestamp}.{payload}").as_bytes());
165
+ let expected = mac.finalize().into_bytes();
166
+ let matches = signatures
167
+ .split(' ')
168
+ .filter_map(|candidate| candidate.strip_prefix("v1,"))
169
+ .filter_map(base64_decode)
170
+ .any(|signature| constant_time_eq(&signature, &expected));
171
+ if matches {
172
+ Ok(())
173
+ } else {
174
+ Err("signature does not match — wrong secret, or the payload was tampered with in transit".to_string())
175
+ }
176
+ }
177
+
178
+ fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
179
+ a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
180
+ }
181
+
182
+ // Standard base64, padded or not; `None` on any other character.
183
+ fn base64_decode(text: &str) -> Option<Vec<u8>> {
184
+ let mut out = Vec::with_capacity(text.len() * 3 / 4);
185
+ let (mut bits, mut have) = (0u32, 0u32);
186
+ for c in text.trim_end_matches('=').bytes() {
187
+ let value = match c {
188
+ b'A'..=b'Z' => c - b'A',
189
+ b'a'..=b'z' => c - b'a' + 26,
190
+ b'0'..=b'9' => c - b'0' + 52,
191
+ b'+' => 62,
192
+ b'/' => 63,
193
+ _ => return None,
194
+ } as u32;
195
+ bits = (bits << 6) | value;
196
+ have += 6;
197
+ if have >= 8 {
198
+ have -= 8;
199
+ out.push((bits >> have) as u8);
200
+ bits &= (1 << have) - 1;
201
+ }
202
+ }
203
+ Some(out)
204
+ }
205
+
206
+ #[cfg(test)]
207
+ mod tests {
208
+ use super::*;
209
+
210
+ const SECRET: &str = "whsec_c2VjcmV0LWtleQ=="; // "secret-key"
211
+
212
+ fn base64_encode(bytes: &[u8]) -> String {
213
+ const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
214
+ let mut out = String::new();
215
+ for chunk in bytes.chunks(3) {
216
+ let n = chunk.iter().enumerate().fold(0u32, |acc, (i, b)| acc | (*b as u32) << (16 - 8 * i));
217
+ for i in 0..=chunk.len() {
218
+ out.push(TABLE[(n >> (18 - 6 * i) & 63) as usize] as char);
219
+ }
220
+ out.extend(std::iter::repeat_n('=', 3 - chunk.len()));
221
+ }
222
+ out
223
+ }
224
+
225
+ fn signed(id: &str, timestamp: &str, body: &str) -> String {
226
+ let mut mac = HmacSha256::new_from_slice(b"secret-key").unwrap();
227
+ mac.update(format!("{id}.{timestamp}.{body}").as_bytes());
228
+ format!("v1,{}", base64_encode(&mac.finalize().into_bytes()))
229
+ }
230
+
231
+ #[test]
232
+ fn base64_round_trips_with_and_without_padding() {
233
+ for sample in [&b""[..], b"f", b"fo", b"foo", b"foob", b"secret-key"] {
234
+ let encoded = base64_encode(sample);
235
+ assert_eq!(base64_decode(&encoded).unwrap(), sample);
236
+ assert_eq!(base64_decode(encoded.trim_end_matches('=')).unwrap(), sample);
237
+ }
238
+ assert!(base64_decode("not base64!").is_none());
239
+ }
240
+
241
+ #[test]
242
+ fn a_correctly_signed_event_verifies() {
243
+ let signature = signed("msg_1", "1000", "{}");
244
+ assert!(verify("{}", "msg_1", "1000", &signature, SECRET, 1000).is_ok());
245
+ }
246
+
247
+ #[test]
248
+ fn any_one_of_several_rotating_signatures_may_match() {
249
+ let signature = format!("v1,AAAA {}", signed("msg_1", "1000", "{}"));
250
+ assert!(verify("{}", "msg_1", "1000", &signature, SECRET, 1000).is_ok());
251
+ }
252
+
253
+ #[test]
254
+ fn a_tampered_body_or_wrong_secret_is_refused() {
255
+ let signature = signed("msg_1", "1000", "{}");
256
+ assert!(verify("{\"x\":1}", "msg_1", "1000", &signature, SECRET, 1000).is_err());
257
+ assert!(verify("{}", "msg_1", "1000", &signature, "whsec_b3RoZXI=", 1000).is_err());
258
+ }
259
+
260
+ #[test]
261
+ fn a_stale_or_unsigned_event_is_refused() {
262
+ let signature = signed("msg_1", "1000", "{}");
263
+ assert!(verify("{}", "msg_1", "1000", &signature, SECRET, 1000 + TOLERANCE_SECONDS + 1).is_err());
264
+ assert!(verify("{}", "", "1000", &signature, SECRET, 1000).is_err());
265
+ assert!(verify("{}", "msg_1", "soon", &signature, SECRET, 1000).is_err());
266
+ }
267
+
268
+ #[test]
269
+ fn opens_and_clicks_name_their_message_and_everything_else_is_ignored() {
270
+ let open = json!({ "type": "email.opened", "data": { "email_id": "re_1" } });
271
+ assert_eq!(tracked_event(&open), Some((Tracked::Open, "re_1".to_string())));
272
+ let click = json!({ "type": "email.clicked", "data": { "email_id": "re_2" } });
273
+ assert_eq!(tracked_event(&click), Some((Tracked::Click, "re_2".to_string())));
274
+ assert_eq!(tracked_event(&json!({ "type": "email.delivered", "data": { "email_id": "re_3" } })), None);
275
+ assert_eq!(tracked_event(&json!({ "type": "email.opened", "data": {} })), None);
276
+ }
277
+
278
+ #[test]
279
+ fn the_delivery_is_found_by_its_stored_message_id() {
280
+ let deliveries = vec![
281
+ ("issue-1:a@b.com".to_string(), json!({ "message_id": { "value": "re_1" } })),
282
+ ("issue-1:c@d.com".to_string(), json!({ "message_id": { "value": "re_2" } })),
283
+ ];
284
+ assert_eq!(delivery_for(&deliveries, "re_2").as_deref(), Some("issue-1:c@d.com"));
285
+ assert_eq!(delivery_for(&deliveries, "re_9"), None);
286
+ }
287
+
288
+ #[test]
289
+ fn the_event_time_is_read_as_unix_seconds() {
290
+ assert_eq!(parse_rfc3339_seconds("1970-01-02T00:00:01.500Z"), Some(86401));
291
+ assert_eq!(parse_rfc3339_seconds("2026-10-06T21:59:20+00:00"), Some(1_791_323_960));
292
+ assert_eq!(parse_rfc3339_seconds("yesterday"), None);
293
+ }
294
+
295
+ #[test]
296
+ fn the_commands_are_the_open_and_click_records() {
297
+ assert_eq!(Tracked::Open.command_suffix(), "RecordOpen");
298
+ assert_eq!(Tracked::Click.command_suffix(), "RecordClick");
299
+ }
300
+ }
data/rust/host/src/web.rs CHANGED
@@ -9,21 +9,26 @@ use crate::field_hints::{EMAIL_HINT, TEL_HINT, TEXTAREA_HINT, URL_HINT};
9
9
  use crate::ir::ir;
10
10
  use crate::journal::LineageConfig;
11
11
  use crate::lambda_client::LambdaInvoker;
12
- use crate::payments;
12
+ use crate::extension::{self, Ctx};
13
13
  use serde_json::{json, Value};
14
14
  use std::collections::HashMap;
15
15
  use std::path::Path;
16
16
  use tokio::sync::Mutex;
17
17
  use tokio_postgres::Client;
18
18
 
19
+ mod commerce;
19
20
  mod newsletter;
20
21
  mod newsletter_send;
21
22
  mod registration_receipt;
22
23
  mod registrations;
24
+ mod resend_webhook;
23
25
 
26
+ pub use commerce::Commerce;
24
27
  use registrations::payments_routes;
25
28
  // payments.rs, its tests and the boot check reach these through `web::`.
26
- pub(crate) use registrations::{checkout_enabled, registration_complete_route, registrations_route, webhook_route, MOCK_STRIPE_WEBHOOK_SECRET};
29
+ pub use registrations::checkout_enabled;
30
+ #[cfg(test)]
31
+ pub(crate) use registrations::{registration_complete_route, registrations_route, webhook_route, MOCK_STRIPE_WEBHOOK_SECRET};
27
32
  #[cfg(test)]
28
33
  pub(crate) use registrations::{seats_left, seats_taken};
29
34
 
@@ -53,30 +58,22 @@ pub async fn render(
53
58
  // not a space (unlike form bodies, which still use parse_form).
54
59
  let query = parse_query(body.get("rawQueryString").and_then(|v| v.as_str()).unwrap_or(""));
55
60
 
56
- // Registration/checkout/newsletter routes are opt-in: they only exist
57
- // when HECKS_CHECKOUT_DOMAIN names this domain, since Event/Registration
58
- // and the Payments::Payment chapter are declared against it specifically.
59
- // The capability shapes are pinned by spec/fixtures/rust_host/checkout_fixture,
60
- // which this module's tests run against.
61
- if checkout_enabled(std::env::var("HECKS_CHECKOUT_DOMAIN").ok().as_deref(), &config.domain) {
62
- // Newsletter subscribe shares this gate (same hecksagon as the
63
- // registration aggregates) and is checked first since it needs none
64
- // of the Payments::Payment/Event context checkout requires.
65
- // The subscriber list is PII (email, names, status): only an Admin or
66
- // Owner holding the account cookie may read it, like sending does.
67
- if method == "GET" && path == "/newsletter/subscribers" && ir().and_then(crate::ir::newsletter_provider).is_some() {
68
- let Some(domain_ir) = ir() else {
69
- return Some(respond(500, "text/plain", "HECKS_IR_PATH not set or unreadable — this domain has no web layer configured"));
70
- };
71
- if let Err(response) = newsletter_send::require_admin(domain_ir, &extract_cookies(body), &session_secret(), client).await {
72
- return Some(response);
73
- }
74
- }
75
- if let Some(response) = newsletter::newsletter_route(method, path, &query, &raw_body, client, wasm_path, config, invoker).await {
76
- return Some(response);
77
- }
78
- let stripe_signature = body.get("headers").and_then(|h| h.get("stripe-signature")).and_then(|v| v.as_str()).unwrap_or("");
79
- if let Some(response) = payments_routes(ir(), method, path, &raw_body, stripe_signature, client, wasm_path, config, invoker).await {
61
+ let cookies = extract_cookies(body);
62
+ let ctx = Ctx {
63
+ event: Some(body),
64
+ method,
65
+ path,
66
+ query: &query,
67
+ raw_body: &raw_body,
68
+ cookies: &cookies,
69
+ domain_ir: ir(),
70
+ client,
71
+ wasm_path,
72
+ config,
73
+ invoker,
74
+ };
75
+ for extension in extension::installed() {
76
+ if let Some(response) = extension.guest_route(&ctx).await {
80
77
  return Some(response);
81
78
  }
82
79
  }
@@ -85,8 +82,6 @@ pub async fn render(
85
82
  return Some(respond(500, "text/plain", "HECKS_IR_PATH not set or unreadable — this domain has no web layer configured"));
86
83
  };
87
84
 
88
- let cookies = extract_cookies(body);
89
-
90
85
  Some(route(domain_ir, method, path, &query, &raw_body, &cookies, client, wasm_path, config, invoker).await)
91
86
  }
92
87
 
@@ -249,6 +244,25 @@ async fn auth_route(
249
244
  config: &LineageConfig,
250
245
  invoker: &dyn LambdaInvoker,
251
246
  ) -> Option<Value> {
247
+ let ctx = Ctx {
248
+ event: None,
249
+ method,
250
+ path,
251
+ query,
252
+ raw_body,
253
+ cookies,
254
+ domain_ir: Some(domain_ir),
255
+ client,
256
+ wasm_path,
257
+ config,
258
+ invoker,
259
+ };
260
+ for extension in extension::installed() {
261
+ if let Some(response) = extension.account_route(&ctx).await {
262
+ return Some(response);
263
+ }
264
+ }
265
+
252
266
  match (method, path) {
253
267
  ("GET", "/login") => Some(html(200, &login_page(query.get("error").map(|s| s.as_str())))),
254
268
 
@@ -270,17 +284,8 @@ async fn auth_route(
270
284
 
271
285
  ("POST", "/members") => Some(add_member_route(domain_ir, raw_body, cookies, secret, client, wasm_path, config).await),
272
286
 
273
- // Sending a newsletter issue (web/newsletter_send.rs's own header):
274
- // an Admin's or Owner's account cookie, unlike the
275
- // guest newsletter routes served ahead of this gate.
276
- (method, path) if newsletter_send::issue_action(method, path).is_some() => {
277
- newsletter_send::issue_route(method, path, domain_ir, raw_body, cookies, secret, client, wasm_path, config, invoker).await
278
- }
279
-
280
287
  ("POST", "/signups") => Some(signup_route(raw_body, secret, client, wasm_path, config, invoker).await),
281
288
 
282
- ("GET", "/registrations") => Some(registrations_list_route(domain_ir, cookies, secret, client, wasm_path, config).await),
283
-
284
289
  ("POST", "/members/disable") => Some(set_member_disabled_route(domain_ir, raw_body, cookies, secret, client, config, true).await),
285
290
 
286
291
  ("POST", "/members/enable") => Some(set_member_disabled_route(domain_ir, raw_body, cookies, secret, client, config, false).await),
@@ -289,15 +294,6 @@ async fn auth_route(
289
294
 
290
295
  ("POST", "/members/delete") => Some(set_member_deleted_route(domain_ir, raw_body, cookies, secret, client, config).await),
291
296
 
292
- // The tenant's payment connection: same gate as the checkout routes,
293
- // since only the HECKS_CHECKOUT_DOMAIN domain carries one — any
294
- // other domain served by this binary falls through as unknown.
295
- (method, path)
296
- if payments::owns(method, path) && checkout_enabled(std::env::var("HECKS_CHECKOUT_DOMAIN").ok().as_deref(), &config.domain) =>
297
- {
298
- payments::route(method, path, raw_body, cookies, secret, domain_ir, &payments::PlatformConfig::load().await, client, wasm_path, config, invoker).await
299
- }
300
-
301
297
  // cms/src/endpoints/sso.ts verifies this with the same account_token
302
298
  // wire format; deploy-aws/platform/template.yaml shares SessionSecret
303
299
  // with the cms container as AUTH_SECRET so both sides agree on it.
@@ -619,7 +615,7 @@ fn registration_list_rows(read: &Value, domain: &str) -> Vec<Value> {
619
615
  };
620
616
  let text = |value: Option<&Value>| plain(value).and_then(|v| v.as_str().map(|s| s.trim().to_string()));
621
617
 
622
- let mut rows: Vec<(Option<String>, Value)> = instances_for(read, &crate::ir::registrations_binding(domain).registration_prefix())
618
+ let mut rows: Vec<(Option<String>, Value)> = instances_for(read, &crate::commerce_ir::registrations_binding(domain).registration_prefix())
623
619
  .into_iter()
624
620
  .map(|(id, registration)| {
625
621
  let attendee = registration.get("attendee").cloned().unwrap_or_else(|| json!({}));
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hecks
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.7.0
4
+ version: 3.9.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Chris Young
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-10-06 00:00:00.000000000 Z
11
+ date: 2026-10-07 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: prism
@@ -644,6 +644,7 @@ files:
644
644
  - lib/hecks/hecks/adapters/shell.rb
645
645
  - lib/hecks/hecks/adapters/site_toolchain.adapter
646
646
  - lib/hecks/hecks/adapters/site_toolchain.rb
647
+ - lib/hecks/hecks/adapters/site_toolchain/prober.rb
647
648
  - lib/hecks/hecks/adapters/terminal.adapter
648
649
  - lib/hecks/hecks/adapters/terminal.rb
649
650
  - lib/hecks/hecks/codebase.behaviors
@@ -1061,6 +1062,7 @@ files:
1061
1062
  - lib/hecks/projections/site/payload/specs.rb
1062
1063
  - lib/hecks/projections/site/payload/specs/wire.rb
1063
1064
  - lib/hecks/projections/site/payload_driver.rb
1065
+ - lib/hecks/projections/site/probe.rb
1064
1066
  - lib/hecks/projections/site/regions.rb
1065
1067
  - lib/hecks/projections/site/root_rows.rb
1066
1068
  - lib/hecks/projections/site/routes_ts.rb
@@ -1472,9 +1474,9 @@ files:
1472
1474
  - lib/rubocop/cop/hecks/fallback_hash_lookup.rb
1473
1475
  - lib/rubocop/cop/hecks/sequential_hash_rename_in_loop.rb
1474
1476
  - lib/rubocop/cop/hecks/thread_shared_ivar_mutation.rb
1475
- - prebuilt/945481eefbdb584649d45f256307053558966a868b9c38dee7ee88f8169610f8.marshal
1476
- - prebuilt/a0818e2de67364dc1ab22868b6cbf869c62da6f8d9b924c778fa5cce2e76ee94.marshal
1477
- - prebuilt/verdicts-be73f0df9eee0abe2f4aadc968321c4b919d19d424a3ccfe1777a71dabf7d9e7.json
1477
+ - prebuilt/0ee7f83cba59ce7d1c45c65ba166b071779f70143449f584f33ff7989f64eccb.marshal
1478
+ - prebuilt/6e4a6bcf0aa880e2e6b056e1846b70e5a768090b9dc998bbadeaf5d6bcfd3db8.marshal
1479
+ - prebuilt/verdicts-91aaf93ce8981bd9a754948f075d95650dc2ec6435015376f5c60992b36a24b1.json
1478
1480
  - qa/settings.yml
1479
1481
  - rust/.cargo/config.toml
1480
1482
  - rust/.cargo/mutants.toml
@@ -1541,6 +1543,7 @@ files:
1541
1543
  - rust/host/src/bin/expr_harness.rs
1542
1544
  - rust/host/src/bin/lineage_harness.rs
1543
1545
  - rust/host/src/bin/mint_harness.rs
1546
+ - rust/host/src/boot.rs
1544
1547
  - rust/host/src/boundary_fuzz/api.rs
1545
1548
  - rust/host/src/boundary_fuzz/auth.rs
1546
1549
  - rust/host/src/boundary_fuzz/expr_json.rs
@@ -1548,13 +1551,16 @@ files:
1548
1551
  - rust/host/src/boundary_fuzz/server.rs
1549
1552
  - rust/host/src/boundary_fuzz/web.rs
1550
1553
  - rust/host/src/checkout.rs
1554
+ - rust/host/src/commerce_ir.rs
1551
1555
  - rust/host/src/dispatch.rs
1552
1556
  - rust/host/src/expr_json.rs
1557
+ - rust/host/src/extension.rs
1553
1558
  - rust/host/src/field_hints.rs
1554
1559
  - rust/host/src/fuzz_support.rs
1555
1560
  - rust/host/src/ir.rs
1556
1561
  - rust/host/src/journal.rs
1557
1562
  - rust/host/src/lambda_client.rs
1563
+ - rust/host/src/lib.rs
1558
1564
  - rust/host/src/log.rs
1559
1565
  - rust/host/src/main.rs
1560
1566
  - rust/host/src/mint.rs
@@ -1576,6 +1582,7 @@ files:
1576
1582
  - rust/host/src/ui_schema.rs
1577
1583
  - rust/host/src/wasm_runner.rs
1578
1584
  - rust/host/src/web.rs
1585
+ - rust/host/src/web/commerce.rs
1579
1586
  - rust/host/src/web/newsletter.rs
1580
1587
  - rust/host/src/web/newsletter/cooldown.rs
1581
1588
  - rust/host/src/web/newsletter/template.rs
@@ -1584,6 +1591,7 @@ files:
1584
1591
  - rust/host/src/web/registration_receipt.rs
1585
1592
  - rust/host/src/web/registration_receipt/tests.rs
1586
1593
  - rust/host/src/web/registrations.rs
1594
+ - rust/host/src/web/resend_webhook.rs
1587
1595
  - rust/lsp/.gitignore
1588
1596
  - rust/lsp/Cargo.lock
1589
1597
  - rust/lsp/Cargo.toml