hecks 3.0.2 → 3.0.4

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 (73) hide show
  1. checksums.yaml +4 -4
  2. data/exe/hecks +6 -2
  3. data/lib/hecks/bluebook/assembly/contracts.rb +3 -1
  4. data/lib/hecks/bluebook/assembly/marks.rb +3 -0
  5. data/lib/hecks/bluebook/command.rb +7 -5
  6. data/lib/hecks/bluebook/dsl/bootstrap_table.rb +1 -0
  7. data/lib/hecks/bluebook/dsl/command_builder.rb +42 -0
  8. data/lib/hecks/bluebook/expression/canonical_form.rb +12 -0
  9. data/lib/hecks/bluebook/expression/projection.json +21 -0
  10. data/lib/hecks/bluebook/meta_validator/shapes.rb +3 -0
  11. data/lib/hecks/cli/project_cli.rb +6 -2
  12. data/lib/hecks/deploy/bluebook/deploy.bluebook +34 -0
  13. data/lib/hecks/doors/cli_runner.rb +57 -12
  14. data/lib/hecks/doors/launcher_options.rb +23 -0
  15. data/lib/hecks/embryonaut_bluebook.rb +10 -0
  16. data/lib/hecks/fuzzing/properties.rb +1 -1
  17. data/lib/hecks/fuzzing/sequence_generator/adversary.rb +11 -3
  18. data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +9 -7
  19. data/lib/hecks/gate/stages.yml +51 -0
  20. data/lib/hecks/grammar/evolve.rb +22 -1
  21. data/lib/hecks/grammar/expression.bluebook +6 -1
  22. data/lib/hecks/grammar/expression_operators.json +135 -0
  23. data/lib/hecks/hecks/adapters/codebase/gate.rb +37 -0
  24. data/lib/hecks/hecks/adapters/codebase/regeneration.rb +24 -4
  25. data/lib/hecks/hecks/adapters/codebase/source_tree.rb +3 -1
  26. data/lib/hecks/hecks/codebase.behaviors +12 -0
  27. data/lib/hecks/hecks/codebase.bluebook +194 -0
  28. data/lib/hecks/hecks/custodian.bluebook +2 -0
  29. data/lib/hecks/hecks/hecks.hecksagon +14 -0
  30. data/lib/hecks/hecks/hecks.world +3 -2
  31. data/lib/hecks/language/bluebook/command.bluebook +26 -0
  32. data/lib/hecks/language/bluebook/vocabulary.bluebook +29 -1
  33. data/lib/hecks/language/oidc.json +5 -0
  34. data/lib/hecks/ports/clock.rb +3 -1
  35. data/lib/hecks/projections/deploy/lambda.rb +3 -4
  36. data/lib/hecks/quality_control/quality_control.bluebook +6 -0
  37. data/lib/hecks/runtime/command_interpreter.rb +6 -4
  38. data/lib/hecks/runtime/entity_interpreter.rb +5 -2
  39. data/lib/hecks/runtime/interpreting.rb +29 -0
  40. data/lib/hecks/runtime/invocation.rb +5 -1
  41. data/lib/hecks/runtime/loader.rb +16 -13
  42. data/lib/hecks/runtime.rb +11 -0
  43. data/lib/hecks/three_zero/forms.yml +1 -1
  44. data/lib/hecks/tools/ci_gate_decision.rb +109 -0
  45. data/lib/hecks/tools/ci_gates.rb +124 -0
  46. data/lib/hecks/tools/gate.rb +117 -0
  47. data/lib/hecks/tools.rb +3 -0
  48. data/lib/hecks/version.rb +1 -1
  49. data/lib/hecks/vocabulary.rb +6 -1
  50. data/lib/hecks.rb +12 -2
  51. data/rust/build/src/resolve.rs +25 -0
  52. data/rust/codegen/src/naming.rs +13 -2
  53. data/rust/host/HECKS_RELEASE +1 -1
  54. data/rust/host/src/dispatch.rs +207 -1
  55. data/rust/host/src/main.rs +1 -0
  56. data/rust/host/src/needs.rs +243 -0
  57. data/rust/host/src/resend.rs +52 -10
  58. data/rust/host/src/server.rs +7 -3
  59. data/rust/host/src/web.rs +38 -5
  60. data/rust/parser/src/canonical.rs +137 -2
  61. data/rust/parser/src/emit.rs +25 -19
  62. data/rust/parser/src/ir.rs +2 -0
  63. data/rust/parser/src/keywords.rs +2 -0
  64. data/rust/parser/src/main.rs +1 -0
  65. data/rust/parser/src/parse/command.rs +44 -0
  66. data/rust/project/naming.rb +4 -2
  67. data/rust/project_rust_pipeline.rb +4 -0
  68. data/rust/src/kernel/cli.rs +3 -0
  69. data/rust/src/kernel/mod.rs +1 -0
  70. data/rust/src/kernel/needs.rs +194 -0
  71. data/rust/src/kernel/orchestrate.rs +84 -0
  72. data/rust/src/kernel/repository.rs +43 -2
  73. metadata +9 -2
@@ -0,0 +1,243 @@
1
+ // Facts a command `needs` (ADR 0081): the host answers them before the kernel reads the arguments,
2
+ // the way the Ruby interpreter does in `decode_arguments`. The command's own IR carries
3
+ // `"needs": [{"fact": "now"}]`; a needed fact the caller left out is filled with the clock's
4
+ // answer, a supplied one is kept. The answer is written into the arguments before they are
5
+ // journaled, so a replay re-dispatches the recorded value rather than asking the clock again.
6
+
7
+ use serde_json::{json, Value};
8
+ use std::sync::atomic::{AtomicI64, Ordering};
9
+
10
+ /// What answers the `now` fact: epoch seconds, UTC.
11
+ pub trait Clock {
12
+ fn now_secs(&self) -> i64;
13
+ }
14
+
15
+ /// The wall clock.
16
+ pub struct SystemClock;
17
+
18
+ impl Clock for SystemClock {
19
+ fn now_secs(&self) -> i64 {
20
+ std::time::SystemTime::now()
21
+ .duration_since(std::time::UNIX_EPOCH)
22
+ .map(|d| d.as_secs() as i64)
23
+ .unwrap_or_default()
24
+ }
25
+ }
26
+
27
+ /// A clock that always answers one moment, for tests.
28
+ #[cfg(test)]
29
+ pub struct FixedClock(pub i64);
30
+
31
+ #[cfg(test)]
32
+ impl Clock for FixedClock {
33
+ fn now_secs(&self) -> i64 {
34
+ self.0
35
+ }
36
+ }
37
+
38
+ const UNSET: i64 = i64::MIN;
39
+ static OVERRIDE: AtomicI64 = AtomicI64::new(UNSET);
40
+
41
+ /// Makes the process clock answer `secs` until `clear_fixed_clock`; for tests only.
42
+ #[cfg(test)]
43
+ pub fn set_fixed_clock(secs: i64) {
44
+ OVERRIDE.store(secs, Ordering::SeqCst);
45
+ }
46
+
47
+ #[cfg(test)]
48
+ pub fn clear_fixed_clock() {
49
+ OVERRIDE.store(UNSET, Ordering::SeqCst);
50
+ }
51
+
52
+ /// The process clock the dispatcher uses: the wall clock unless a test has fixed it.
53
+ pub struct ProcessClock;
54
+
55
+ impl Clock for ProcessClock {
56
+ fn now_secs(&self) -> i64 {
57
+ match OVERRIDE.load(Ordering::SeqCst) {
58
+ UNSET => SystemClock.now_secs(),
59
+ fixed => fixed,
60
+ }
61
+ }
62
+ }
63
+
64
+ /// The command's declared needs, each with the type of the attribute of the same name.
65
+ fn declared_needs<'a>(domain_ir: &'a Value, verb: &str) -> Vec<(&'a str, Option<&'a str>)> {
66
+ let Some((qualified_aggregate, command_name)) = verb.rsplit_once('.') else { return Vec::new() };
67
+ let aggregate = qualified_aggregate.rsplit("::").next().unwrap_or(qualified_aggregate);
68
+ let named = |value: &Value, name: &str| value.get("name").and_then(Value::as_str) == Some(name);
69
+ let list = |value: &'a Value, key: &str| -> Vec<&'a Value> {
70
+ value.get(key).and_then(Value::as_array).map(|a| a.iter().collect()).unwrap_or_default()
71
+ };
72
+ let mut needs = Vec::new();
73
+ for command in list(domain_ir, "aggregates")
74
+ .into_iter()
75
+ .filter(|a| named(a, aggregate))
76
+ .flat_map(|a| list(a, "commands"))
77
+ .filter(|c| named(c, command_name))
78
+ {
79
+ let attributes = list(command, "attributes");
80
+ for fact in list(command, "needs").into_iter().filter_map(|n| n.get("fact").and_then(Value::as_str)) {
81
+ let attribute_type =
82
+ attributes.iter().find(|a| named(a, fact)).and_then(|a| a.get("type")).and_then(Value::as_str);
83
+ needs.push((fact, attribute_type));
84
+ }
85
+ }
86
+ needs
87
+ }
88
+
89
+ /// Every command's needs as the kernel's input `"needs"` reads them: `{ verb: [{fact, type}] }`.
90
+ /// The kernel answers a reaction's command from this (it has no IR of its own), and the same
91
+ /// step's `occurred_at` keeps the answer the same on replay.
92
+ pub fn table(domain_ir: &Value) -> Value {
93
+ let domain = domain_ir.get("name").and_then(Value::as_str).unwrap_or_default();
94
+ let mut verbs = serde_json::Map::new();
95
+ let aggregates = domain_ir.get("aggregates").and_then(Value::as_array).into_iter().flatten();
96
+ for aggregate in aggregates {
97
+ let aggregate_name = aggregate.get("name").and_then(Value::as_str).unwrap_or_default();
98
+ for command in aggregate.get("commands").and_then(Value::as_array).into_iter().flatten() {
99
+ let command_name = command.get("name").and_then(Value::as_str).unwrap_or_default();
100
+ let verb = format!("{domain}::{aggregate_name}.{command_name}");
101
+ let needs: Vec<Value> = declared_needs(domain_ir, &verb)
102
+ .into_iter()
103
+ .map(|(fact, attribute_type)| json!({"fact": fact, "type": attribute_type.unwrap_or("")}))
104
+ .collect();
105
+ if !needs.is_empty() {
106
+ verbs.insert(verb, Value::Array(needs));
107
+ }
108
+ }
109
+ }
110
+ Value::Object(verbs)
111
+ }
112
+
113
+ /// The facts of a command invocation: the `with` object when the call carries one, else the
114
+ /// flat object itself (the kernel's `CommandInvocation` reads both shapes).
115
+ fn facts_mut(args: &mut Value) -> Option<&mut serde_json::Map<String, Value>> {
116
+ if args.get("with").is_some_and(Value::is_object) {
117
+ return args.get_mut("with").and_then(Value::as_object_mut);
118
+ }
119
+ args.as_object_mut()
120
+ }
121
+
122
+ fn answer(fact: &str, attribute_type: Option<&str>, clock: &dyn Clock) -> Option<Value> {
123
+ match fact {
124
+ "now" => {
125
+ let secs = clock.now_secs();
126
+ Some(if attribute_type == Some("Integer") { json!(secs) } else { json!({ "value": secs }) })
127
+ }
128
+ _ => None,
129
+ }
130
+ }
131
+
132
+ /// Fills each fact `verb` needs that `args` leaves out, before any gate reads the arguments. A key
133
+ /// the caller supplied (even a null) is kept; a command that needs nothing is untouched.
134
+ pub fn fill_needs(domain_ir: &Value, verb: &str, args: &mut Value, clock: &dyn Clock) {
135
+ let needs = declared_needs(domain_ir, verb);
136
+ if needs.is_empty() {
137
+ return;
138
+ }
139
+ let Some(facts) = facts_mut(args) else { return };
140
+ for (fact, attribute_type) in needs {
141
+ if facts.contains_key(fact) {
142
+ continue;
143
+ }
144
+ if let Some(value) = answer(fact, attribute_type, clock) {
145
+ facts.insert(fact.to_string(), value);
146
+ }
147
+ }
148
+ }
149
+
150
+ #[cfg(test)]
151
+ mod tests {
152
+ use super::*;
153
+
154
+ const LEASE_IR: &str = include_str!("../../src/generated/lease_clock/ir.json");
155
+
156
+ fn lease_ir() -> Value {
157
+ serde_json::from_str(LEASE_IR).unwrap()
158
+ }
159
+
160
+ // A command whose `now` is an Integer attribute, as QualityControl's are.
161
+ fn integer_ir() -> Value {
162
+ json!({"name": "Qc", "aggregates": [{"name": "Check", "commands": [
163
+ {"name": "Run", "attributes": [{"name": "now", "type": "Integer"}], "needs": [{"fact": "now"}]},
164
+ {"name": "Plain", "attributes": [{"name": "now", "type": "Integer"}], "needs": []}
165
+ ]}]})
166
+ }
167
+
168
+ fn first_needing(ir: &Value) -> String {
169
+ ir["aggregates"]
170
+ .as_array()
171
+ .unwrap()
172
+ .iter()
173
+ .flat_map(|a| {
174
+ a["commands"].as_array().unwrap().iter().filter(|c| !c["needs"].as_array().unwrap().is_empty()).map(
175
+ move |c| format!("{}::{}.{}", ir["name"].as_str().unwrap(), a["name"].as_str().unwrap(), c["name"].as_str().unwrap()),
176
+ )
177
+ })
178
+ .next()
179
+ .expect("the lease_clock domain declares a command that needs :now")
180
+ }
181
+
182
+ #[test]
183
+ fn a_needed_fact_the_caller_left_out_is_filled_from_the_clock() {
184
+ let ir = lease_ir();
185
+ let mut args = json!({"with": {"holder": "a"}});
186
+ fill_needs(&ir, &first_needing(&ir), &mut args, &FixedClock(1_700_000_000));
187
+ assert_eq!(args["with"]["now"], json!({"value": 1_700_000_000}));
188
+ assert_eq!(args["with"]["holder"], "a");
189
+ }
190
+
191
+ #[test]
192
+ fn a_flat_invocation_is_filled_at_its_top_level() {
193
+ let ir = lease_ir();
194
+ let mut args = json!({"holder": "a"});
195
+ fill_needs(&ir, &first_needing(&ir), &mut args, &FixedClock(5));
196
+ assert_eq!(args["now"], json!({"value": 5}));
197
+ }
198
+
199
+ #[test]
200
+ fn a_value_the_caller_supplied_is_kept() {
201
+ let ir = lease_ir();
202
+ let mut args = json!({"with": {"now": {"value": 42}}});
203
+ fill_needs(&ir, &first_needing(&ir), &mut args, &FixedClock(7));
204
+ assert_eq!(args["with"]["now"], json!({"value": 42}));
205
+ }
206
+
207
+ #[test]
208
+ fn a_command_with_no_needs_is_untouched() {
209
+ let ir = integer_ir();
210
+ let mut args = json!({"with": {"x": 1}});
211
+ fill_needs(&ir, "Qc::Check.Plain", &mut args, &FixedClock(7));
212
+ assert_eq!(args, json!({"with": {"x": 1}}));
213
+ let mut unknown = json!({"with": {}});
214
+ fill_needs(&ir, "Qc::Check.Nope", &mut unknown, &FixedClock(7));
215
+ assert_eq!(unknown, json!({"with": {}}));
216
+ }
217
+
218
+ #[test]
219
+ fn an_integer_attribute_gets_a_bare_integer_and_an_instant_a_value_object() {
220
+ let mut integer = json!({"with": {}});
221
+ fill_needs(&integer_ir(), "Qc::Check.Run", &mut integer, &FixedClock(9));
222
+ assert_eq!(integer["with"]["now"], json!(9));
223
+ let ir = lease_ir();
224
+ let mut instant = json!({"with": {}});
225
+ fill_needs(&ir, &first_needing(&ir), &mut instant, &FixedClock(9));
226
+ assert_eq!(instant["with"]["now"], json!({"value": 9}));
227
+ }
228
+
229
+ #[test]
230
+ fn the_kernel_table_lists_only_commands_that_need_something() {
231
+ let table = table(&integer_ir());
232
+ assert_eq!(table, json!({"Qc::Check.Run": [{"fact": "now", "type": "Integer"}]}));
233
+ assert_eq!(super::table(&json!({"name": "D"})), json!({}));
234
+ }
235
+
236
+ #[test]
237
+ fn the_process_clock_answers_a_fixed_moment_and_otherwise_the_wall_clock() {
238
+ set_fixed_clock(123);
239
+ assert_eq!(ProcessClock.now_secs(), 123);
240
+ clear_fixed_clock();
241
+ assert!(ProcessClock.now_secs() > 1_700_000_000);
242
+ }
243
+ }
@@ -43,7 +43,7 @@ impl Delivery {
43
43
 
44
44
  /// How email leaves the host: really, through Resend, or logged and dropped.
45
45
  pub enum Mailer {
46
- Live { http: reqwest::Client, api_key: String, from: String, base_url: String },
46
+ Live { http: reqwest::Client, api_key: String, from: String, reply_to: Option<String>, base_url: String },
47
47
  Mock,
48
48
  }
49
49
 
@@ -53,6 +53,7 @@ impl Mailer {
53
53
  pub fn from_env() -> Result<Option<Mailer>, String> {
54
54
  let var = |name: &str| std::env::var(name).unwrap_or_default();
55
55
  Self::configured(&var("RESEND_API_KEY"), &var("RESEND_FROM"), &var("RESEND_MOCK"))
56
+ .map(|mailer| mailer.map(|mailer| mailer.replying_to(&var("RESEND_REPLY_TO"))))
56
57
  }
57
58
 
58
59
  fn configured(api_key: &str, from: &str, mock: &str) -> Result<Option<Mailer>, String> {
@@ -69,7 +70,19 @@ impl Mailer {
69
70
  /// A mailer that posts to `base_url` (Resend's API, or a stand-in in tests).
70
71
  pub fn live(api_key: &str, from: &str, base_url: &str) -> Mailer {
71
72
  let http = reqwest::Client::builder().timeout(TIMEOUT).build().expect("a reqwest client with a timeout always builds");
72
- Mailer::Live { http, api_key: api_key.to_string(), from: from.to_string(), base_url: base_url.to_string() }
73
+ Mailer::Live { http, api_key: api_key.to_string(), from: from.to_string(), reply_to: None, base_url: base_url.to_string() }
74
+ }
75
+
76
+ /// Sets where replies go (`Reply-To`), when the address people reply to is not the one the mail
77
+ /// is sent from. Blank leaves replies going to the From address. A mock mailer has no replies.
78
+ pub fn replying_to(self, reply_to: &str) -> Mailer {
79
+ match self {
80
+ Mailer::Live { http, api_key, from, base_url, .. } => {
81
+ let reply_to = (!reply_to.trim().is_empty()).then(|| reply_to.trim().to_string());
82
+ Mailer::Live { http, api_key, from, reply_to, base_url }
83
+ }
84
+ mock => mock,
85
+ }
73
86
  }
74
87
 
75
88
  #[cfg(test)]
@@ -80,7 +93,7 @@ impl Mailer {
80
93
  pub async fn deliver(&self, email: &Email<'_>) -> Delivery {
81
94
  match self {
82
95
  Mailer::Mock => mock_deliver(email),
83
- Mailer::Live { http, api_key, from, base_url } => live_deliver(http, api_key, from, base_url, email).await,
96
+ Mailer::Live { http, api_key, from, reply_to, base_url } => live_deliver(http, api_key, from, reply_to.as_deref(), base_url, email).await,
84
97
  }
85
98
  }
86
99
  }
@@ -93,8 +106,11 @@ fn mock_deliver(email: &Email<'_>) -> Delivery {
93
106
  Delivery::sent(format!("re_{}", uuid::Uuid::new_v4().simple()))
94
107
  }
95
108
 
96
- fn payload(from: &str, email: &Email<'_>) -> Value {
109
+ fn payload(from: &str, reply_to: Option<&str>, email: &Email<'_>) -> Value {
97
110
  let mut payload = json!({ "from": from, "to": [email.to], "subject": email.subject });
111
+ if let Some(reply_to) = reply_to {
112
+ payload["reply_to"] = json!(reply_to);
113
+ }
98
114
  let content_key = if email.body.trim_start().starts_with('<') { "html" } else { "text" };
99
115
  payload[content_key] = json!(email.body);
100
116
  if let Some(url) = email.unsubscribe_url {
@@ -113,8 +129,8 @@ fn retry_delay(response: &reqwest::Response) -> Duration {
113
129
  Duration::from_secs(secs.min(MAX_RETRY_SECS))
114
130
  }
115
131
 
116
- async fn live_deliver(http: &reqwest::Client, api_key: &str, from: &str, base_url: &str, email: &Email<'_>) -> Delivery {
117
- let body = payload(from, email);
132
+ async fn live_deliver(http: &reqwest::Client, api_key: &str, from: &str, reply_to: Option<&str>, base_url: &str, email: &Email<'_>) -> Delivery {
133
+ let body = payload(from, reply_to, email);
118
134
  for attempt in 1..=MAX_ATTEMPTS {
119
135
  let response = match http.post(format!("{base_url}/emails")).bearer_auth(api_key).json(&body).send().await {
120
136
  Ok(r) => r,
@@ -225,19 +241,45 @@ mod tests {
225
241
 
226
242
  #[test]
227
243
  fn an_html_body_goes_as_html_and_a_plain_one_as_text() {
228
- assert!(payload("f", &email("a@b.com", " <p>hi</p>")).get("html").is_some());
229
- let plain = payload("f", &email("a@b.com", "just a link"));
244
+ assert!(payload("f", None, &email("a@b.com", " <p>hi</p>")).get("html").is_some());
245
+ let plain = payload("f", None, &email("a@b.com", "just a link"));
230
246
  assert!(plain.get("text").is_some() && plain.get("html").is_none());
231
247
  }
232
248
 
233
249
  #[test]
234
250
  fn the_unsubscribe_url_rides_as_a_list_unsubscribe_header() {
235
- let with = payload("f", &email("a@b.com", "x"));
251
+ let with = payload("f", None, &email("a@b.com", "x"));
236
252
  assert_eq!(with["headers"]["List-Unsubscribe"], "<https://example.com/newsletter-unsubscribed.html?email=a%40b.com>");
237
- let without = payload("f", &Email { to: "a@b.com", subject: "s", body: "x", unsubscribe_url: None });
253
+ let without = payload("f", None, &Email { to: "a@b.com", subject: "s", body: "x", unsubscribe_url: None });
238
254
  assert!(without.get("headers").is_none());
239
255
  }
240
256
 
257
+ #[test]
258
+ fn a_reply_to_address_rides_in_the_payload_and_is_absent_without_one() {
259
+ let with = payload("f", Some("lifeadelics@gmail.com"), &email("a@b.com", "x"));
260
+ assert_eq!(with["reply_to"], "lifeadelics@gmail.com");
261
+ assert!(payload("f", None, &email("a@b.com", "x")).get("reply_to").is_none());
262
+ }
263
+
264
+ #[test]
265
+ fn replying_to_trims_the_address_and_a_blank_one_sets_none() {
266
+ let set = Mailer::live("re_x", "n@mail.example.com", "http://x").replying_to(" me@example.com ");
267
+ assert!(matches!(&set, Mailer::Live { reply_to: Some(r), .. } if r == "me@example.com"));
268
+ let blank = Mailer::live("re_x", "n@mail.example.com", "http://x").replying_to(" ");
269
+ assert!(matches!(&blank, Mailer::Live { reply_to: None, .. }));
270
+ assert!(Mailer::Mock.replying_to("me@example.com").is_mock(), "a mock mailer has no replies");
271
+ }
272
+
273
+ #[tokio::test]
274
+ async fn a_live_send_carries_the_reply_to_address_to_resend() {
275
+ let fake = FakeResend::start(vec![("200 OK", "", r#"{"id":"em_9"}"#)]);
276
+ let mailer = Mailer::live("re_x", "News <n@mail.example.com>", &fake.base).replying_to("lifeadelics@gmail.com");
277
+
278
+ mailer.deliver(&email("a@b.com", "x")).await;
279
+
280
+ assert!(fake.requests()[0].contains(r#""reply_to":"lifeadelics@gmail.com""#), "{}", fake.requests()[0]);
281
+ }
282
+
241
283
  #[test]
242
284
  fn the_mock_logs_instead_of_sending_and_bounces_the_test_address() {
243
285
  let sent = mock_deliver(&email("a@b.com", "x"));
@@ -53,25 +53,29 @@ pub async fn dispatch_body(
53
53
  .ok_or("event missing \"verb\"")?
54
54
  .to_string();
55
55
  let role = body.get("role").and_then(|v| v.as_str()).map(|s| s.to_string());
56
+ // Who the caller is, for Governance. Honored only where the internal protocol is: from this
57
+ // host's own peers (`trusts_internal_dispatch`), never from a public caller.
58
+ let actor_id = body.get("actor_id").and_then(|v| v.as_str()).map(|s| s.to_string());
59
+ let caller = dispatch::Caller { role: role.as_deref(), actor_id: actor_id.as_deref() };
56
60
 
57
61
  let outcome = if let Some(to) = body.get("to").cloned() {
58
62
  if body.get("args").is_some() {
59
63
  return Err("cannot combine to/with with legacy args".into());
60
64
  }
61
65
  let facts = body.get("with").cloned().unwrap_or_else(|| serde_json::json!({}));
62
- dispatch::handle_routed(client, wasm_path, &verb, to, facts, role.as_deref(), config, invoker)
66
+ dispatch::handle_routed_as(client, wasm_path, &verb, to, facts, caller, config, invoker)
63
67
  .await
64
68
  .map_err(|e| format!("{e:#}"))?
65
69
  } else if let Some(facts) = body.get("with").cloned() {
66
70
  if body.get("args").is_some() {
67
71
  return Err("cannot combine \"with\" with legacy args".into());
68
72
  }
69
- dispatch::handle_facts(client, wasm_path, &verb, facts, role.as_deref(), config, invoker)
73
+ dispatch::handle_facts_as(client, wasm_path, &verb, facts, caller, config, invoker)
70
74
  .await
71
75
  .map_err(|e| format!("{e:#}"))?
72
76
  } else {
73
77
  let args = body.get("args").cloned().unwrap_or_else(|| serde_json::json!({}));
74
- dispatch::handle(client, wasm_path, &verb, args, role.as_deref(), config, invoker)
78
+ dispatch::handle_as(client, wasm_path, &verb, args, caller, config, invoker)
75
79
  .await
76
80
  .map_err(|e| format!("{e:#}"))?
77
81
  };
data/rust/host/src/web.rs CHANGED
@@ -373,10 +373,11 @@ async fn accounts_me_route(domain_ir: &Value, cookies: &HashMap<String, String>,
373
373
  }
374
374
  }
375
375
 
376
- // GET /members: admitted people as JSON, for a caller holding the
377
- // account cookie but not the Governance session /admin/members needs.
376
+ // GET /members: admitted people as JSON, for an Admin or Owner holding the
377
+ // account cookie but not the Governance session /admin/members needs. The list
378
+ // is every person's name, email and role, so a plain member does not get it.
378
379
  async fn members_route(domain_ir: &Value, cookies: &HashMap<String, String>, secret: &str, client: &Mutex<Client>) -> Value {
379
- if let Err(response) = active_session_email(domain_ir, cookies, secret, client).await {
380
+ if let Err(response) = newsletter_send::require_admin(domain_ir, cookies, secret, client).await {
380
381
  return response;
381
382
  }
382
383
  match auth::all_people(client, domain_ir).await {
@@ -2302,6 +2303,32 @@ mod tests {
2302
2303
  }
2303
2304
  }
2304
2305
 
2306
+ #[tokio::test]
2307
+ async fn members_route_refuses_a_plain_member_with_a_403_and_no_people() {
2308
+ let secret = "s3cret";
2309
+ let (client, domain_ir) = scratch_members_db("hecks_host_web_test_members_route_403").await;
2310
+ client
2311
+ .lock()
2312
+ .await
2313
+ .execute(
2314
+ "INSERT INTO acme_member_head_snapshot_1 (id, ordinal, state) VALUES ($1, 0, $2::jsonb)",
2315
+ &[
2316
+ &"max@example.com",
2317
+ &json!({"name": {"value": "Max"}, "email": {"value": "max@example.com"},
2318
+ "role": {"value": "Member"}, "identity_id": {"value": "id-2"}}),
2319
+ ],
2320
+ )
2321
+ .await
2322
+ .unwrap();
2323
+
2324
+ let response = members_route(&domain_ir, &session_cookies(secret, "max@example.com"), secret, &client).await;
2325
+ assert_eq!(response["statusCode"], 403, "{response:?}");
2326
+ let body = response["body"].as_str().unwrap();
2327
+ for leaked in ["zed@example.com", "amy@example.com", "Zed"] {
2328
+ assert!(!body.contains(leaked), "{leaked:?} leaked to a plain member: {body}");
2329
+ }
2330
+ }
2331
+
2305
2332
  fn session_cookies(secret: &str, email: &str) -> HashMap<String, String> {
2306
2333
  let mut cookies = HashMap::new();
2307
2334
  cookies.insert(auth::account_cookie_name(), auth::account_token(secret, email, 60));
@@ -2808,9 +2835,15 @@ mod tests {
2808
2835
  } else {
2809
2836
  assert_eq!((add, registrations, disable, enable), (403, 403, 403, 403), "{caller} is refused everywhere");
2810
2837
  }
2811
- // Listing needs access, not admin: a Member may list, a stranger may not.
2838
+ // Listing is an admin gate too: it carries every person's email and role, so an
2839
+ // Admin or Owner lists, a Member is refused, and a stranger has no session at all.
2812
2840
  let listing = status_of(members_route(&domain_ir, &cookies, secret, &client).await).await;
2813
- assert_eq!(listing, if caller == "stranger@example.com" { 401 } else { 200 }, "{caller} listing");
2841
+ let expected = match caller {
2842
+ "stranger@example.com" => 401,
2843
+ _ if admits => 200,
2844
+ _ => 403,
2845
+ };
2846
+ assert_eq!(listing, expected, "{caller} listing");
2814
2847
  }
2815
2848
 
2816
2849
  // Anonymous: 401 on every route, before any role is looked at.
@@ -1,5 +1,5 @@
1
1
  //! Source-body slicing and whitespace collapse, mirroring Ruby's `CanonicalForm`.
2
- //! Hand-mirrored from projection.json: a third rule there needs a matching rule here.
2
+ //! Hand-mirrored from projection.json: a rule there (collapse_whitespace, replace, scale_call) needs a matching rule here.
3
3
 
4
4
  /// Slices the raw source between two byte offsets, before any normalisation.
5
5
  pub fn slice(source: &str, start: usize, end: usize) -> &str {
@@ -11,7 +11,87 @@ pub fn slice(source: &str, start: usize, end: usize) -> &str {
11
11
  pub fn apply(source: &str) -> String {
12
12
  let collapsed = map_outside_strings(source, collapse_whitespace);
13
13
  let replaced = map_outside_strings(&collapsed, |run| replace_word_boundary(run, ".length", ".size"));
14
- replaced.trim().to_string()
14
+ let scaled = SCALE_CALLS.iter().fold(replaced, |text, (token, seconds)| {
15
+ map_outside_strings(&text, |run| scale_call(run, token, seconds))
16
+ });
17
+ scaled.trim().to_string()
18
+ }
19
+
20
+ /// The `scale_call` rows of projection.json, in position order: (call name, seconds per unit).
21
+ const SCALE_CALLS: [(&str, &str); 3] = [("days", "86400"), ("hours", "3600"), ("minutes", "60")];
22
+
23
+ /// Every normalisation row of projection.json as (strategy, source_token, replacement,
24
+ /// boundary, position), in position order; emitted into the IR's expression table.
25
+ pub const TABLE: [(&str, &str, &str, &str, &str); 5] = [
26
+ ("collapse_whitespace", "", "", "none", "1"),
27
+ ("replace", ".length", ".size", "word", "2"),
28
+ ("scale_call", "days", "86400", "word", "3"),
29
+ ("scale_call", "hours", "3600", "word", "4"),
30
+ ("scale_call", "minutes", "60", "word", "5"),
31
+ ];
32
+
33
+ /// Folds `token(<whole number>)` into the number times `seconds`, as Ruby's `scale_call` does.
34
+ /// The name must not follow a word character, digit, underscore or dot; spaces inside the
35
+ /// parentheses are allowed; any other argument leaves the call as written.
36
+ fn scale_call(text: &str, token: &str, seconds: &str) -> String {
37
+ let mut out = String::with_capacity(text.len());
38
+ let mut i = 0;
39
+ while i < text.len() {
40
+ if text[i..].starts_with(token) {
41
+ let preceded = text[..i]
42
+ .chars()
43
+ .next_back()
44
+ .map_or(false, |c| c.is_alphanumeric() || c == '_' || c == '.');
45
+ if !preceded {
46
+ if let Some((digits, len)) = whole_number_call(&text[i + token.len()..]) {
47
+ out.push_str(&multiply(digits, seconds));
48
+ i += token.len() + len;
49
+ continue;
50
+ }
51
+ }
52
+ }
53
+ let ch = text[i..].chars().next().unwrap();
54
+ out.push(ch);
55
+ i += ch.len_utf8();
56
+ }
57
+ out
58
+ }
59
+
60
+ /// Matches `(` spaces digits spaces `)` at the start of `rest`; returns the digits and the
61
+ /// matched length.
62
+ fn whole_number_call(rest: &str) -> Option<(&str, usize)> {
63
+ let inner = rest.strip_prefix('(')?;
64
+ let digits_at = inner.len() - inner.trim_start_matches(char::is_whitespace).len();
65
+ let body = &inner[digits_at..];
66
+ let digit_len = body.bytes().take_while(|b| b.is_ascii_digit()).count();
67
+ if digit_len == 0 {
68
+ return None;
69
+ }
70
+ let tail = &body[digit_len..];
71
+ let close = tail.len() - tail.trim_start_matches(char::is_whitespace).len();
72
+ tail[close..].strip_prefix(')')?;
73
+ Some((&body[..digit_len], 1 + digits_at + digit_len + close + 1))
74
+ }
75
+
76
+ /// Decimal `digits` times the decimal `factor`, as text with no leading zeros (any length).
77
+ fn multiply(digits: &str, factor: &str) -> String {
78
+ let factor: u32 = factor.parse().expect("scale factor is a whole number");
79
+ let mut carry = 0u32;
80
+ let mut out: Vec<u8> = Vec::with_capacity(digits.len() + 6);
81
+ for d in digits.bytes().rev() {
82
+ let v = (d - b'0') as u32 * factor + carry;
83
+ out.push(b'0' + (v % 10) as u8);
84
+ carry = v / 10;
85
+ }
86
+ while carry > 0 {
87
+ out.push(b'0' + (carry % 10) as u8);
88
+ carry /= 10;
89
+ }
90
+ while out.len() > 1 && out.last() == Some(&b'0') {
91
+ out.pop();
92
+ }
93
+ out.reverse();
94
+ String::from_utf8(out).unwrap()
15
95
  }
16
96
 
17
97
  /// Applies `transform` to the unquoted runs of `text` and copies quoted runs verbatim;
@@ -104,6 +184,61 @@ mod tests {
104
184
  assert_eq!(apply("lengthy"), "lengthy");
105
185
  }
106
186
 
187
+ #[test]
188
+ fn folds_duration_calls_into_seconds() {
189
+ assert_eq!(apply("days(730)"), "63072000");
190
+ assert_eq!(apply("days( 1 ) + hours(2) + minutes(15)"), "86400 + 7200 + 900");
191
+ assert_eq!(
192
+ apply("x.days(3) workdays(3) days(n) days(1.5)"),
193
+ "x.days(3) workdays(3) days(n) days(1.5)"
194
+ );
195
+ assert_eq!(apply("'days(2)' == days(0)"), "'days(2)' == 0");
196
+ }
197
+
198
+ /// Splits JSON text into its string literals, decoding escapes.
199
+ fn json_strings(text: &str) -> Vec<String> {
200
+ let mut out = Vec::new();
201
+ let mut chars = text.chars();
202
+ while let Some(c) = chars.next() {
203
+ if c != '"' {
204
+ continue;
205
+ }
206
+ let mut s = String::new();
207
+ while let Some(c) = chars.next() {
208
+ match c {
209
+ '"' => break,
210
+ '\\' => match chars.next().unwrap() {
211
+ 'n' => s.push('\n'),
212
+ 't' => s.push('\t'),
213
+ 'r' => s.push('\r'),
214
+ 'u' => {
215
+ let hex: String = chars.by_ref().take(4).collect();
216
+ s.push(char::from_u32(u32::from_str_radix(&hex, 16).unwrap()).unwrap());
217
+ }
218
+ other => s.push(other),
219
+ },
220
+ c => s.push(c),
221
+ }
222
+ }
223
+ out.push(s);
224
+ }
225
+ out
226
+ }
227
+
228
+ #[test]
229
+ fn matches_the_shared_canonical_form_cases() {
230
+ let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../spec/fixtures/canonical_form_cases.json");
231
+ let text = std::fs::read_to_string(path).expect("shared cases file");
232
+ let strings = json_strings(&text);
233
+ let start = strings.iter().position(|s| s == "cases").expect("cases key") + 1;
234
+ let rows: Vec<&[String]> = strings[start..].chunks(4).collect();
235
+ assert!(!rows.is_empty());
236
+ for row in rows {
237
+ assert_eq!((row[0].as_str(), row[2].as_str()), ("source", "canonical"));
238
+ assert_eq!(apply(&row[1]), row[3], "source {:?}", row[1]);
239
+ }
240
+ }
241
+
107
242
  #[test]
108
243
  fn slices_the_exact_source_span() {
109
244
  let source = "given(\"ok\") { balance >= amount }";