hecks 3.0.3 → 3.0.5
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 +12 -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/console.rb +24 -8
- data/lib/hecks/cli/project_cli.rb +42 -9
- data/lib/hecks/cli/seed_semantics_corpus.rb +35 -6
- 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 +41 -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 +55 -0
- 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 +5 -1
- 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/ports/persistence/plugins/era/postgres_era.rb +2 -1
- data/lib/hecks/projections/deploy/lambda.rb +3 -4
- data/lib/hecks/projector/cli_projector.rb +63 -4
- data/lib/hecks/quality_control/quality_control.bluebook +6 -0
- data/lib/hecks/runtime/command_interpreter.rb +8 -4
- data/lib/hecks/runtime/entity_interpreter.rb +5 -2
- data/lib/hecks/runtime/interpreting.rb +50 -0
- data/lib/hecks/runtime/invocation.rb +7 -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/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/newsletter/template.rs +69 -0
- data/rust/host/src/web/newsletter/tests.rs +43 -0
- data/rust/host/src/web/newsletter.rs +6 -1
- data/rust/host/src/web/newsletter_send.rs +1 -1
- 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/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 +10 -2
data/rust/host/src/dispatch.rs
CHANGED
|
@@ -14,6 +14,61 @@ pub struct Outcome {
|
|
|
14
14
|
pub accepted: bool,
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
/// Who a command is dispatched for: the role they state and, when the host knows who they are, the
|
|
18
|
+
/// actor Governance holds assignments for. Both absent is an unidentified caller.
|
|
19
|
+
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
|
20
|
+
pub struct Caller<'a> {
|
|
21
|
+
pub role: Option<&'a str>,
|
|
22
|
+
pub actor_id: Option<&'a str>,
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/// The role an unidentified caller is dispatched under when roles are enforced: no command declares
|
|
26
|
+
/// it, so a command that declares any role refuses it.
|
|
27
|
+
pub const ANONYMOUS_ROLE: &str = "Anonymous";
|
|
28
|
+
|
|
29
|
+
/// What the host does about the role a command declares (`HECKS_ROLE_ENFORCEMENT`).
|
|
30
|
+
#[derive(Clone, Copy, Debug, PartialEq)]
|
|
31
|
+
pub enum Enforcement {
|
|
32
|
+
/// A caller with no role is unchecked, as before.
|
|
33
|
+
Off,
|
|
34
|
+
/// An unidentified or unassigned caller is let through and logged as `would_refuse_role`.
|
|
35
|
+
Shadow,
|
|
36
|
+
/// An unidentified or unassigned caller is refused.
|
|
37
|
+
Enforce,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
impl Enforcement {
|
|
41
|
+
/// Reads `off` (the default), `shadow` or `enforce`; anything else is `Off`.
|
|
42
|
+
pub fn parse(value: Option<&str>) -> Self {
|
|
43
|
+
match value.map(|v| v.trim().to_ascii_lowercase()).as_deref() {
|
|
44
|
+
Some("shadow") => Self::Shadow,
|
|
45
|
+
Some("enforce") => Self::Enforce,
|
|
46
|
+
_ => Self::Off,
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
pub fn from_env() -> Self {
|
|
51
|
+
static MODE: std::sync::OnceLock<Enforcement> = std::sync::OnceLock::new();
|
|
52
|
+
*MODE.get_or_init(|| Self::parse(std::env::var("HECKS_ROLE_ENFORCEMENT").ok().as_deref()))
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/// The caller the kernel is asked to check: an unidentified caller becomes the anonymous role
|
|
56
|
+
/// unless enforcement is off.
|
|
57
|
+
pub fn effective<'a>(self, caller: Caller<'a>) -> Caller<'a> {
|
|
58
|
+
if self != Self::Off && caller.role.is_none() && caller.actor_id.is_none() {
|
|
59
|
+
return Caller { role: Some(ANONYMOUS_ROLE), actor_id: None };
|
|
60
|
+
}
|
|
61
|
+
caller
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/// Whether the kernel refused the command for the caller's role.
|
|
66
|
+
fn refused_for_role(result: &serde_json::Value) -> bool {
|
|
67
|
+
result.get("refusals").and_then(|r| r.as_array()).is_some_and(|all| {
|
|
68
|
+
all.iter().any(|r| r.get("kind").and_then(|k| k.as_str()) == Some("Unauthorized"))
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
|
|
17
72
|
// Distinguishes a module refusal from a kernel failure to run at all.
|
|
18
73
|
#[derive(Debug, PartialEq)]
|
|
19
74
|
enum KernelAnswer {
|
|
@@ -80,6 +135,21 @@ pub async fn handle_routed(
|
|
|
80
135
|
handle(client, wasm_path, verb, routed_invocation(to, facts)?, role, config, invoker).await
|
|
81
136
|
}
|
|
82
137
|
|
|
138
|
+
/// `handle_routed` for a caller the host can name.
|
|
139
|
+
#[allow(clippy::too_many_arguments)]
|
|
140
|
+
pub async fn handle_routed_as(
|
|
141
|
+
client: &Mutex<Client>,
|
|
142
|
+
wasm_path: &Path,
|
|
143
|
+
verb: &str,
|
|
144
|
+
to: serde_json::Value,
|
|
145
|
+
facts: serde_json::Value,
|
|
146
|
+
caller: Caller<'_>,
|
|
147
|
+
config: &LineageConfig,
|
|
148
|
+
invoker: &dyn LambdaInvoker,
|
|
149
|
+
) -> anyhow::Result<Outcome> {
|
|
150
|
+
handle_as(client, wasm_path, verb, routed_invocation(to, facts)?, caller, config, invoker).await
|
|
151
|
+
}
|
|
152
|
+
|
|
83
153
|
// Facts-only counterpart to `handle_routed`.
|
|
84
154
|
#[allow(clippy::too_many_arguments)]
|
|
85
155
|
pub async fn handle_facts(
|
|
@@ -94,6 +164,20 @@ pub async fn handle_facts(
|
|
|
94
164
|
handle(client, wasm_path, verb, facts_invocation(facts)?, role, config, invoker).await
|
|
95
165
|
}
|
|
96
166
|
|
|
167
|
+
/// `handle_facts` for a caller the host can name.
|
|
168
|
+
#[allow(clippy::too_many_arguments)]
|
|
169
|
+
pub async fn handle_facts_as(
|
|
170
|
+
client: &Mutex<Client>,
|
|
171
|
+
wasm_path: &Path,
|
|
172
|
+
verb: &str,
|
|
173
|
+
facts: serde_json::Value,
|
|
174
|
+
caller: Caller<'_>,
|
|
175
|
+
config: &LineageConfig,
|
|
176
|
+
invoker: &dyn LambdaInvoker,
|
|
177
|
+
) -> anyhow::Result<Outcome> {
|
|
178
|
+
handle_as(client, wasm_path, verb, facts_invocation(facts)?, caller, config, invoker).await
|
|
179
|
+
}
|
|
180
|
+
|
|
97
181
|
// A Mutex, not a bare Arc<Client>: `handle` needs `Client::transaction`,
|
|
98
182
|
// which takes `&mut Client`. Locking also means concurrent callers on
|
|
99
183
|
// this connection can't interleave statements.
|
|
@@ -114,6 +198,57 @@ pub async fn handle(
|
|
|
114
198
|
config: &LineageConfig,
|
|
115
199
|
invoker: &dyn LambdaInvoker,
|
|
116
200
|
) -> anyhow::Result<Outcome> {
|
|
201
|
+
handle_as(client, wasm_path, verb, args, Caller { role, actor_id: None }, config, invoker).await
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/// Dispatches for a caller, applying `HECKS_ROLE_ENFORCEMENT`: unidentified callers are refused (or,
|
|
205
|
+
/// in shadow, logged as would-be refusals and let through).
|
|
206
|
+
pub async fn handle_as(
|
|
207
|
+
client: &Mutex<Client>,
|
|
208
|
+
wasm_path: &Path,
|
|
209
|
+
verb: &str,
|
|
210
|
+
args: serde_json::Value,
|
|
211
|
+
caller: Caller<'_>,
|
|
212
|
+
config: &LineageConfig,
|
|
213
|
+
invoker: &dyn LambdaInvoker,
|
|
214
|
+
) -> anyhow::Result<Outcome> {
|
|
215
|
+
handle_with(Enforcement::from_env(), client, wasm_path, verb, args, caller, config, invoker).await
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
#[allow(clippy::too_many_arguments)]
|
|
219
|
+
async fn handle_with(
|
|
220
|
+
mode: Enforcement,
|
|
221
|
+
client: &Mutex<Client>,
|
|
222
|
+
wasm_path: &Path,
|
|
223
|
+
verb: &str,
|
|
224
|
+
args: serde_json::Value,
|
|
225
|
+
caller: Caller<'_>,
|
|
226
|
+
config: &LineageConfig,
|
|
227
|
+
invoker: &dyn LambdaInvoker,
|
|
228
|
+
) -> anyhow::Result<Outcome> {
|
|
229
|
+
let checked = mode.effective(caller);
|
|
230
|
+
let outcome = run(client, wasm_path, verb, args.clone(), checked, config, invoker).await?;
|
|
231
|
+
if mode == Enforcement::Shadow && !outcome.accepted && refused_for_role(&outcome.result) {
|
|
232
|
+
crate::log::error("would_refuse_role", serde_json::json!({
|
|
233
|
+
"verb": verb, "role": checked.role, "actor_id": checked.actor_id,
|
|
234
|
+
"refusals": outcome.result.get("refusals"),
|
|
235
|
+
}));
|
|
236
|
+
return run(client, wasm_path, verb, args, Caller::default(), config, invoker).await;
|
|
237
|
+
}
|
|
238
|
+
Ok(outcome)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
#[allow(clippy::too_many_arguments)]
|
|
242
|
+
async fn run(
|
|
243
|
+
client: &Mutex<Client>,
|
|
244
|
+
wasm_path: &Path,
|
|
245
|
+
verb: &str,
|
|
246
|
+
args: serde_json::Value,
|
|
247
|
+
caller: Caller<'_>,
|
|
248
|
+
config: &LineageConfig,
|
|
249
|
+
invoker: &dyn LambdaInvoker,
|
|
250
|
+
) -> anyhow::Result<Outcome> {
|
|
251
|
+
let role = caller.role;
|
|
117
252
|
let mut guard = client.lock().await;
|
|
118
253
|
let txn = guard.transaction().await?;
|
|
119
254
|
|
|
@@ -147,10 +282,19 @@ pub async fn handle(
|
|
|
147
282
|
// carries whatever role it was dispatched with. Built as a mutable
|
|
148
283
|
// step, not inlined into the `json!` literal, so a roleless call
|
|
149
284
|
// still omits the `"role"` key entirely rather than sending null.
|
|
285
|
+
// A fact the command `needs` is answered here, before the kernel's gates read the arguments and
|
|
286
|
+
// before the step is journaled, so a replay re-dispatches the recorded answer (ADR 0081).
|
|
287
|
+
let mut args = args;
|
|
288
|
+
if let Some(domain_ir) = crate::ir::ir() {
|
|
289
|
+
crate::needs::fill_needs(domain_ir, verb, &mut args, &crate::needs::ProcessClock);
|
|
290
|
+
}
|
|
150
291
|
let mut step = serde_json::json!({ "verb": verb, "args": args.clone() });
|
|
151
292
|
if let Some(role) = role {
|
|
152
293
|
step["role"] = serde_json::Value::String(role.to_string());
|
|
153
294
|
}
|
|
295
|
+
if let Some(actor_id) = caller.actor_id {
|
|
296
|
+
step["actor_id"] = serde_json::Value::String(actor_id.to_string());
|
|
297
|
+
}
|
|
154
298
|
// Stamped only on this live step; replayed history already carries
|
|
155
299
|
// its own real timestamp, and re-stamping today's time onto a
|
|
156
300
|
// replay would be wrong, not just redundant.
|
|
@@ -179,7 +323,15 @@ pub async fn handle(
|
|
|
179
323
|
}))
|
|
180
324
|
.collect::<Vec<_>>());
|
|
181
325
|
|
|
182
|
-
let input = serde_json::json!({ "seed": seed, "steps": steps, "sagas": sagas_seed })
|
|
326
|
+
let mut input = serde_json::json!({ "seed": seed, "steps": steps, "sagas": sagas_seed });
|
|
327
|
+
// Lets the kernel answer a reaction's command that needs a fact the same way (ADR 0081).
|
|
328
|
+
if let Some(domain_ir) = crate::ir::ir() {
|
|
329
|
+
let table = crate::needs::table(domain_ir);
|
|
330
|
+
if table.as_object().is_some_and(|t| !t.is_empty()) {
|
|
331
|
+
input["needs"] = table;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
let input = input.to_string();
|
|
183
335
|
// `wasm_runner::run` is sync, and wasmtime-wasi's sync bridge spins up
|
|
184
336
|
// its own tokio runtime internally — fatal on a thread already
|
|
185
337
|
// driving one. `spawn_blocking` moves it off this async runtime.
|
|
@@ -1169,6 +1321,60 @@ pub(crate) mod tests {
|
|
|
1169
1321
|
assert_eq!(steps.len(), 1, "only the correctly-authorized registration should be persisted");
|
|
1170
1322
|
}
|
|
1171
1323
|
|
|
1324
|
+
#[test]
|
|
1325
|
+
fn enforcement_reads_off_shadow_and_enforce_and_treats_anything_else_as_off() {
|
|
1326
|
+
assert_eq!(Enforcement::parse(None), Enforcement::Off);
|
|
1327
|
+
assert_eq!(Enforcement::parse(Some("")), Enforcement::Off);
|
|
1328
|
+
assert_eq!(Enforcement::parse(Some("nonsense")), Enforcement::Off);
|
|
1329
|
+
assert_eq!(Enforcement::parse(Some("Shadow")), Enforcement::Shadow);
|
|
1330
|
+
assert_eq!(Enforcement::parse(Some(" enforce ")), Enforcement::Enforce);
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
#[test]
|
|
1334
|
+
fn only_an_unidentified_caller_becomes_anonymous_and_only_when_roles_are_checked() {
|
|
1335
|
+
let nobody = Caller::default();
|
|
1336
|
+
assert_eq!(Enforcement::Off.effective(nobody), nobody);
|
|
1337
|
+
for mode in [Enforcement::Shadow, Enforcement::Enforce] {
|
|
1338
|
+
assert_eq!(mode.effective(nobody).role, Some(ANONYMOUS_ROLE));
|
|
1339
|
+
let stated = Caller { role: Some("Teller"), actor_id: None };
|
|
1340
|
+
assert_eq!(mode.effective(stated), stated, "a stated role is kept");
|
|
1341
|
+
let identified = Caller { role: None, actor_id: Some("u1") };
|
|
1342
|
+
assert_eq!(mode.effective(identified), identified, "an identified caller is kept");
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
// The unidentified caller is the case the host never refused: no role at all.
|
|
1347
|
+
#[tokio::test]
|
|
1348
|
+
async fn an_unidentified_caller_is_refused_when_enforced_and_let_through_when_off_or_shadowed() {
|
|
1349
|
+
let client = scratch_db("rust_host_dispatch_test_enforce").await;
|
|
1350
|
+
provision_lineage(&*client.lock().await, "Banking", 1, &["Customer"]).await;
|
|
1351
|
+
let config = test_config("Banking", 1);
|
|
1352
|
+
let go = |mode: Enforcement, id: &'static str| {
|
|
1353
|
+
let client = &client;
|
|
1354
|
+
let config = &config;
|
|
1355
|
+
async move {
|
|
1356
|
+
handle_with(mode, client, &wasm_path(), "Banking::Customer.Register", register(id), Caller::default(), config, &lambda_client::NeverInvoker)
|
|
1357
|
+
.await
|
|
1358
|
+
.unwrap()
|
|
1359
|
+
}
|
|
1360
|
+
};
|
|
1361
|
+
|
|
1362
|
+
let off = go(Enforcement::Off, "CUST-0021").await;
|
|
1363
|
+
assert!(off.accepted, "off keeps the old behaviour: {:?}", off.result);
|
|
1364
|
+
|
|
1365
|
+
let enforced = go(Enforcement::Enforce, "CUST-0022").await;
|
|
1366
|
+
assert!(!enforced.accepted, "an unidentified caller must be refused: {:?}", enforced.result);
|
|
1367
|
+
assert!(refused_for_role(&enforced.result), "{:?}", enforced.result);
|
|
1368
|
+
let error = enforced.result["refusals"][0]["error"].as_str().unwrap();
|
|
1369
|
+
assert!(error.contains("Branch clerk") && error.contains(ANONYMOUS_ROLE), "{error}");
|
|
1370
|
+
|
|
1371
|
+
let shadowed = go(Enforcement::Shadow, "CUST-0023").await;
|
|
1372
|
+
assert!(shadowed.accepted, "shadow lets the command through: {:?}", shadowed.result);
|
|
1373
|
+
|
|
1374
|
+
let steps = journal::load_steps(&*client.lock().await).await.unwrap();
|
|
1375
|
+
assert_eq!(steps.len(), 2, "the enforced refusal was not persisted; off and shadow were");
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1172
1378
|
#[tokio::test]
|
|
1173
1379
|
async fn a_mid_transaction_failure_rolls_back_any_saga_state_already_written() {
|
|
1174
1380
|
// Deliberately doesn't provision "Transfer": its mutation fails
|
data/rust/host/src/main.rs
CHANGED
|
@@ -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
|
};
|