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.
- checksums.yaml +4 -4
- data/exe/hecks +6 -2
- data/lib/hecks/bluebook/assembly/contracts.rb +3 -1
- data/lib/hecks/bluebook/assembly/marks.rb +3 -0
- data/lib/hecks/bluebook/command.rb +7 -5
- data/lib/hecks/bluebook/dsl/bootstrap_table.rb +1 -0
- data/lib/hecks/bluebook/dsl/command_builder.rb +42 -0
- data/lib/hecks/bluebook/expression/canonical_form.rb +12 -0
- data/lib/hecks/bluebook/expression/projection.json +21 -0
- data/lib/hecks/bluebook/meta_validator/shapes.rb +3 -0
- data/lib/hecks/cli/project_cli.rb +6 -2
- data/lib/hecks/deploy/bluebook/deploy.bluebook +34 -0
- data/lib/hecks/doors/cli_runner.rb +57 -12
- data/lib/hecks/doors/launcher_options.rb +23 -0
- data/lib/hecks/embryonaut_bluebook.rb +10 -0
- data/lib/hecks/fuzzing/properties.rb +1 -1
- data/lib/hecks/fuzzing/sequence_generator/adversary.rb +11 -3
- data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +9 -7
- data/lib/hecks/gate/stages.yml +51 -0
- data/lib/hecks/grammar/evolve.rb +22 -1
- data/lib/hecks/grammar/expression.bluebook +6 -1
- data/lib/hecks/grammar/expression_operators.json +135 -0
- data/lib/hecks/hecks/adapters/codebase/gate.rb +37 -0
- data/lib/hecks/hecks/adapters/codebase/regeneration.rb +24 -4
- data/lib/hecks/hecks/adapters/codebase/source_tree.rb +3 -1
- data/lib/hecks/hecks/codebase.behaviors +12 -0
- data/lib/hecks/hecks/codebase.bluebook +194 -0
- data/lib/hecks/hecks/custodian.bluebook +2 -0
- data/lib/hecks/hecks/hecks.hecksagon +14 -0
- data/lib/hecks/hecks/hecks.world +3 -2
- data/lib/hecks/language/bluebook/command.bluebook +26 -0
- data/lib/hecks/language/bluebook/vocabulary.bluebook +29 -1
- data/lib/hecks/language/oidc.json +5 -0
- data/lib/hecks/ports/clock.rb +3 -1
- data/lib/hecks/projections/deploy/lambda.rb +3 -4
- data/lib/hecks/quality_control/quality_control.bluebook +6 -0
- data/lib/hecks/runtime/command_interpreter.rb +6 -4
- data/lib/hecks/runtime/entity_interpreter.rb +5 -2
- data/lib/hecks/runtime/interpreting.rb +29 -0
- data/lib/hecks/runtime/invocation.rb +5 -1
- data/lib/hecks/runtime/loader.rb +16 -13
- data/lib/hecks/runtime.rb +11 -0
- data/lib/hecks/three_zero/forms.yml +1 -1
- data/lib/hecks/tools/ci_gate_decision.rb +109 -0
- data/lib/hecks/tools/ci_gates.rb +124 -0
- data/lib/hecks/tools/gate.rb +117 -0
- data/lib/hecks/tools.rb +3 -0
- data/lib/hecks/version.rb +1 -1
- data/lib/hecks/vocabulary.rb +6 -1
- data/lib/hecks.rb +12 -2
- data/rust/build/src/resolve.rs +25 -0
- data/rust/codegen/src/naming.rs +13 -2
- data/rust/host/HECKS_RELEASE +1 -1
- data/rust/host/src/dispatch.rs +207 -1
- data/rust/host/src/main.rs +1 -0
- data/rust/host/src/needs.rs +243 -0
- data/rust/host/src/resend.rs +52 -10
- data/rust/host/src/server.rs +7 -3
- data/rust/host/src/web.rs +38 -5
- data/rust/parser/src/canonical.rs +137 -2
- data/rust/parser/src/emit.rs +25 -19
- data/rust/parser/src/ir.rs +2 -0
- data/rust/parser/src/keywords.rs +2 -0
- data/rust/parser/src/main.rs +1 -0
- data/rust/parser/src/parse/command.rs +44 -0
- data/rust/project/naming.rb +4 -2
- data/rust/project_rust_pipeline.rb +4 -0
- data/rust/src/kernel/cli.rs +3 -0
- data/rust/src/kernel/mod.rs +1 -0
- data/rust/src/kernel/needs.rs +194 -0
- data/rust/src/kernel/orchestrate.rs +84 -0
- data/rust/src/kernel/repository.rs +43 -2
- 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
|
+
}
|
data/rust/host/src/resend.rs
CHANGED
|
@@ -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"));
|
data/rust/host/src/server.rs
CHANGED
|
@@ -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::
|
|
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::
|
|
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::
|
|
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
|
|
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) =
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 }";
|