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.
- checksums.yaml +4 -4
- data/lib/hecks/bluebook/meta_validator/modes.rb +14 -0
- data/lib/hecks/bluebook/meta_validator.rb +4 -2
- data/lib/hecks/corpus/routes.rb +1 -1
- data/lib/hecks/gate/stages.yml +11 -1
- data/lib/hecks/hecks/adapters/site_toolchain/prober.rb +64 -0
- data/lib/hecks/hecks/adapters/site_toolchain.rb +41 -5
- data/lib/hecks/language/bluebook/vocabulary.bluebook +1 -1
- data/lib/hecks/projections/deploy/shared_database_instance/templates/rds.yaml.tmpl +1 -1
- data/lib/hecks/projections/deploy/templates/lambda/template.yaml.tmpl +1 -1
- data/lib/hecks/projections/site/probe.rb +131 -0
- data/lib/hecks/projections/site/site_host/dockerfile.rb +1 -1
- data/lib/hecks/runtime/value/validation.rb +34 -3
- data/lib/hecks/site/bluebook/site.bluebook +31 -0
- data/lib/hecks/site/bluebook/site.ports.hecksagon +6 -0
- data/lib/hecks/version.rb +1 -1
- data/lib/hecks/vocabulary.rb +1 -1
- data/prebuilt/{verdicts-be73f0df9eee0abe2f4aadc968321c4b919d19d424a3ccfe1777a71dabf7d9e7.json → verdicts-91aaf93ce8981bd9a754948f075d95650dc2ec6435015376f5c60992b36a24b1.json} +1 -1
- data/rust/codegen/src/fielded.rs +3 -3
- data/rust/codegen/src/json_codec.rs +2 -2
- data/rust/codegen/src/naming.rs +20 -0
- data/rust/host/Cargo.toml +4 -0
- data/rust/host/HECKS_RELEASE +1 -1
- data/rust/host/src/boot.rs +443 -0
- data/rust/host/src/boundary_fuzz/ir.rs +1 -0
- data/rust/host/src/commerce_ir.rs +459 -0
- data/rust/host/src/extension.rs +100 -0
- data/rust/host/src/ir.rs +0 -448
- data/rust/host/src/journal.rs +1 -1
- data/rust/host/src/lib.rs +40 -0
- data/rust/host/src/main.rs +4 -477
- data/rust/host/src/mint.rs +1 -1
- data/rust/host/src/payments/tests.rs +8 -8
- data/rust/host/src/payments.rs +10 -10
- data/rust/host/src/rate_limit.rs +27 -41
- data/rust/host/src/secrets.rs +15 -0
- data/rust/host/src/web/commerce.rs +121 -0
- data/rust/host/src/web/newsletter.rs +2 -1
- data/rust/host/src/web/newsletter_send.rs +1 -1
- data/rust/host/src/web/registration_receipt.rs +2 -2
- data/rust/host/src/web/registrations.rs +49 -49
- data/rust/host/src/web/resend_webhook.rs +300 -0
- data/rust/host/src/web.rs +43 -47
- metadata +13 -5
- /data/prebuilt/{a0818e2de67364dc1ab22868b6cbf869c62da6f8d9b924c778fa5cce2e76ee94.marshal → 0ee7f83cba59ce7d1c45c65ba166b071779f70143449f584f33ff7989f64eccb.marshal} +0 -0
- /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::
|
|
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
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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::
|
|
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.
|
|
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-
|
|
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/
|
|
1476
|
-
- prebuilt/
|
|
1477
|
-
- prebuilt/verdicts-
|
|
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
|
|
File without changes
|