hecks 3.9.0 → 3.10.0

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/lib/hecks/deploy/bluebook/deploy.bluebook +106 -0
  3. data/lib/hecks/deploy/bluebook/deploy.hecksagon +8 -0
  4. data/lib/hecks/deploy/oidc.json +16 -3
  5. data/lib/hecks/hecks/adapters/deploy_toolchain/clearing.rb +51 -0
  6. data/lib/hecks/hecks/adapters/deploy_toolchain.rb +2 -0
  7. data/lib/hecks/hecks/adapters/site_toolchain/role_prober.rb +78 -0
  8. data/lib/hecks/hecks/adapters/site_toolchain.rb +29 -4
  9. data/lib/hecks/hecks/adapters/world_clearing.rb +65 -0
  10. data/lib/hecks/hecks/hecks.hecksagon +8 -0
  11. data/lib/hecks/hecks/hecks.world +2 -1
  12. data/lib/hecks/projections/deploy/fargate/settings/tuning.rb +15 -0
  13. data/lib/hecks/projections/deploy/fargate/settings.rb +3 -1
  14. data/lib/hecks/projections/deploy/fargate/stack/readers.rb +1 -0
  15. data/lib/hecks/projections/deploy/templates/fargate/Makefile.tmpl +7 -4
  16. data/lib/hecks/projections/deploy/templates/lambda/template.yaml.tmpl +1 -1
  17. data/lib/hecks/projections/site/cms_editor/attributes.rb +75 -0
  18. data/lib/hecks/projections/site/cms_editor/checks.rb +64 -0
  19. data/lib/hecks/projections/site/cms_editor/media.rb +85 -0
  20. data/lib/hecks/projections/site/cms_editor/pretty.rb +40 -0
  21. data/lib/hecks/projections/site/cms_editor/schema.rb +84 -0
  22. data/lib/hecks/projections/site/cms_editor/setting.rb +87 -0
  23. data/lib/hecks/projections/site/cms_editor/templates/package.json.tmpl +19 -0
  24. data/lib/hecks/projections/site/cms_editor/templates/src/app.ts.tmpl +176 -0
  25. data/lib/hecks/projections/site/cms_editor/templates/src/auth/membership.ts.tmpl +79 -0
  26. data/lib/hecks/projections/site/cms_editor/templates/src/auth/session.ts.tmpl +36 -0
  27. data/lib/hecks/projections/site/cms_editor/templates/src/auth/sso.ts.tmpl +20 -0
  28. data/lib/hecks/projections/site/cms_editor/templates/src/commands.ts.tmpl +79 -0
  29. data/lib/hecks/projections/site/cms_editor/templates/src/config.ts.tmpl +23 -0
  30. data/lib/hecks/projections/site/cms_editor/templates/src/host.ts.tmpl +26 -0
  31. data/lib/hecks/projections/site/cms_editor/templates/src/media/handler.ts.tmpl +154 -0
  32. data/lib/hecks/projections/site/cms_editor/templates/src/media/multipart.ts.tmpl +53 -0
  33. data/lib/hecks/projections/site/cms_editor/templates/src/media/sniff.ts.tmpl +94 -0
  34. data/lib/hecks/projections/site/cms_editor/templates/src/media/storage.ts.tmpl +50 -0
  35. data/lib/hecks/projections/site/cms_editor/templates/src/schema.ts.tmpl +85 -0
  36. data/lib/hecks/projections/site/cms_editor/templates/src/server.ts.tmpl +65 -0
  37. data/lib/hecks/projections/site/cms_editor/templates/src/ui/body_model.js.tmpl +120 -0
  38. data/lib/hecks/projections/site/cms_editor/templates/src/ui/body_parse.js.tmpl +320 -0
  39. data/lib/hecks/projections/site/cms_editor/templates/src/ui/body_widget.js.tmpl +282 -0
  40. data/lib/hecks/projections/site/cms_editor/templates/src/ui/fields.ts.tmpl +80 -0
  41. data/lib/hecks/projections/site/cms_editor/templates/src/ui/html.ts.tmpl +102 -0
  42. data/lib/hecks/projections/site/cms_editor/templates/src/ui/input.ts.tmpl +75 -0
  43. data/lib/hecks/projections/site/cms_editor/templates/src/ui/media_picker.js.tmpl +109 -0
  44. data/lib/hecks/projections/site/cms_editor/templates/src/ui/outline.ts.tmpl +27 -0
  45. data/lib/hecks/projections/site/cms_editor/templates/src/ui/pages.ts.tmpl +123 -0
  46. data/lib/hecks/projections/site/cms_editor/templates/tsconfig.json.tmpl +16 -0
  47. data/lib/hecks/projections/site/cms_editor.rb +107 -0
  48. data/lib/hecks/projections/site/role_probe.rb +72 -0
  49. data/lib/hecks/site/bluebook/site.bluebook +28 -1
  50. data/lib/hecks/site/bluebook/site.ports.hecksagon +6 -0
  51. data/lib/hecks/tools/site_routes/cli.rb +1 -1
  52. data/lib/hecks/tools/site_routes/editor_files.rb +38 -0
  53. data/lib/hecks/tools/site_routes.rb +14 -7
  54. data/lib/hecks/version.rb +1 -1
  55. data/prebuilt/{verdicts-3230a251570aa03666708d16f92e646feab6a3f8cc2a3540c017d748e41d41a1.json → verdicts-d6d584ad43b5f3cebf88aafbbdbf024c38574acf18e06adffbc555f7786b1c2f.json} +1 -1
  56. data/rust/Cargo.toml +3 -1
  57. data/rust/codegen/src/bridging.rs +1 -1
  58. data/rust/codegen/src/domain_generator.rs +9 -9
  59. data/rust/codegen/src/main.rs +1 -1
  60. data/rust/codegen/src/mutations.rs +46 -1
  61. data/rust/codegen/src/naming.rs +30 -0
  62. data/rust/host/HECKS_RELEASE +1 -1
  63. data/rust/host/src/boot.rs +443 -0
  64. data/rust/host/src/lib.rs +1 -0
  65. data/rust/host/src/main.rs +4 -438
  66. data/rust/host/src/secrets.rs +15 -0
  67. data/rust/host/src/web/commerce.rs +11 -3
  68. data/rust/host/src/web/resend_webhook.rs +2 -1
  69. data/rust/src/kernel/expr.rs +7 -1
  70. data/rust/src/kernel/expression_operators/presence.rs +41 -3
  71. metadata +42 -5
  72. /data/prebuilt/{4e6c3cd1f578a38732ea5454dae52562d03d8e9886216077a5c34cbf589f3463.marshal → c28d6d9db14eea3e17161e9d9c262efee30b256cf97693a7cfd552bce24b2f7f.marshal} +0 -0
  73. /data/prebuilt/{911cdcc56b5a094ed26583a4c76401dba6455b1f434722ed22b850bc1b1bdd28.marshal → cb375cba61ca0dbe8ddb9739c306510fe05e5f4b7a9b6955caff8224c63ea0e0.marshal} +0 -0
data/rust/Cargo.toml CHANGED
@@ -5,7 +5,9 @@
5
5
  # domain was generated most recently; any other stays reachable via
6
6
  # `--no-default-features --features <name>`.
7
7
  [features]
8
- default = ["read_model_on_target_fixture"]
8
+ default = ["reserved_names_fixture"]
9
+ optional_value_object_fixture = []
10
+ reserved_names_fixture = []
9
11
  composite_line_ledger = []
10
12
  read_model_on_target_fixture = []
11
13
  group_by_collision_fixture = []
@@ -29,7 +29,7 @@ pub fn cross_aggregate_vo_imports(aggregate: &Json, domain_value_object_owner: &
29
29
 
30
30
  let mut pairs: Vec<(String, String)> = foreign_types.into_iter().filter_map(|type_name| domain_value_object_owner.get(&type_name).map(|owner| (type_name, owner.clone()))).collect();
31
31
  pairs.sort_by(|(a_type, a_owner), (b_type, b_owner)| (a_owner, a_type).cmp(&(b_owner, b_type)));
32
- pairs.into_iter().map(|(type_name, owner)| format!("use crate::generated::{mod_name}::{}::{};", owner.to_lowercase(), naming::rust_ident(&type_name))).collect()
32
+ pairs.into_iter().map(|(type_name, owner)| format!("use crate::generated::{mod_name}::{}::{};", naming::aggregate_module(&owner), naming::rust_ident(&type_name))).collect()
33
33
  }
34
34
 
35
35
  /// True when a value object rebuilds into a differently-named one by matching field names.
@@ -85,8 +85,8 @@ fn reference_checks(
85
85
  target_mod: target
86
86
  .get("name")
87
87
  .and_then(Json::as_str)
88
- .unwrap_or("")
89
- .to_lowercase(),
88
+ .map(crate::naming::aggregate_module)
89
+ .unwrap_or_default(),
90
90
  target_name: target
91
91
  .get("name")
92
92
  .and_then(Json::as_str)
@@ -161,8 +161,8 @@ fn state_reference_checks(
161
161
  target_mod: target
162
162
  .get("name")
163
163
  .and_then(Json::as_str)
164
- .unwrap_or("")
165
- .to_lowercase(),
164
+ .map(crate::naming::aggregate_module)
165
+ .unwrap_or_default(),
166
166
  target_name: target
167
167
  .get("name")
168
168
  .and_then(Json::as_str)
@@ -225,7 +225,7 @@ fn tenant_boundary_checks(
225
225
 
226
226
  Some(TenantBoundaryCheck {
227
227
  reference_field: crate::attr::name(attr).to_string(),
228
- target_mod: target_decl_name.to_lowercase(),
228
+ target_mod: crate::naming::aggregate_module(&target_decl_name),
229
229
  target_name: target_decl_name,
230
230
  aggregate_name: aggregate_name.clone(),
231
231
  own_tenant_field: own_tenant_field.clone(),
@@ -1036,13 +1036,13 @@ pub fn generate(
1036
1036
  }
1037
1037
 
1038
1038
  aggregate_files.push(GeneratedFile {
1039
- name: format!("{}.rs", agg_name.to_lowercase()),
1039
+ name: format!("{}.rs", crate::naming::aggregate_module(agg_name)),
1040
1040
  content: out,
1041
1041
  });
1042
1042
 
1043
1043
  registry_aggregates.push(AggregateEntry {
1044
1044
  name: agg_name.to_string(),
1045
- module_name: agg_name.to_lowercase(),
1045
+ module_name: crate::naming::aggregate_module(agg_name),
1046
1046
  record: record_name,
1047
1047
  commands: registry_commands,
1048
1048
  entity_commands,
@@ -1092,7 +1092,7 @@ pub fn generate(
1092
1092
  query_defs.push(queries::QueryDef {
1093
1093
  verb: format!("{domain_name}::{agg_name}.{query_name}"),
1094
1094
  aggregate: format!("{domain_name}::{agg_name}"),
1095
- arg_checks: queries::query_arg_checks(query, &format!("crate::generated::{mod_name}::{}", agg_name.to_lowercase()), &value_objects_by_name),
1095
+ arg_checks: queries::query_arg_checks(query, &format!("crate::generated::{mod_name}::{}", crate::naming::aggregate_module(agg_name)), &value_objects_by_name),
1096
1096
  conditions,
1097
1097
  reference_hop_conditions,
1098
1098
  order_by: query.get("order_by").map(|ob| queries::emit_query_order_by(ob, query.get("null_semantics"))),
@@ -1273,7 +1273,7 @@ pub fn generate(
1273
1273
  puts_str(&mut mod_rs, "pub mod metadata;");
1274
1274
  puts_str(&mut mod_rs, "pub mod registry;");
1275
1275
  for a in &registry_aggregates {
1276
- puts_str(&mut mod_rs, &format!("pub mod {};", a.name.to_lowercase()));
1276
+ puts_str(&mut mod_rs, &format!("pub mod {};", a.module_name));
1277
1277
  }
1278
1278
 
1279
1279
  GeneratedDomain {
@@ -73,7 +73,7 @@ fn run_prelude(args: &[String]) -> Result<(), String> {
73
73
  let name = aggregate.get("name").and_then(Json::as_str).unwrap_or("");
74
74
  match prelude::aggregate_prelude(&ex, &ir, aggregate, source_label) {
75
75
  Some(text) => {
76
- let path = format!("{out_dir}/{}.rs", name.to_lowercase());
76
+ let path = format!("{out_dir}/{}.rs", naming::aggregate_module(name));
77
77
  std::fs::write(&path, text).map_err(|e| format!("writing {path}: {e}"))?;
78
78
  println!("wrote {path}");
79
79
  }
@@ -455,6 +455,16 @@ pub fn lifecycle_transition_for(command: &Json, aggregate: &Json) -> Option<Tran
455
455
  Some(Transition { field, to_state, from_states, unconstrained })
456
456
  }
457
457
 
458
+ /// Whether a `state(:field)` source reads an `Option` out of the pre-dispatch record: the field
459
+ /// is declared optional, or the record holds every field as an `Option` (`record_optional`).
460
+ /// Such a source already has the target's wrapping, so it must not be wrapped in `Some` again.
461
+ fn state_source_is_option(source: Option<&Json>, aggregate: &Json, record_optional: bool) -> bool {
462
+ let Some(source) = source.filter(|s| s.get("kind").map(Json::to_s).unwrap_or_default() == "state") else { return false };
463
+ let name = source.get("name").map(Json::to_s).unwrap_or_default();
464
+ let attrs = aggregate.get("attributes").map(Json::each).unwrap_or(&[]);
465
+ record_optional || attrs.iter().any(|a| crate::attr::name(a) == name && crate::attr::optional(a))
466
+ }
467
+
458
468
  /// Right-hand side for a `set`, coercing the source into the target attribute's declared type.
459
469
  ///
460
470
  /// `target_list` routes list-to-list sets through `list_value_rhs`; `value_rhs` is scalar-only.
@@ -731,7 +741,8 @@ fn emit_mutation_line_body(
731
741
  let (guard, effective_rhs) = entity_list_replace_guard(aggregate, target_attr, &target_field, &rhs, value_objects_by_name);
732
742
  format!("{guard}{}", exemplar.render("mutation_set_plain", &[("tmpl_field", target_field.to_string()), ("tmpl_rhs_placeholder2()", effective_rhs)]))
733
743
  } else {
734
- let wrap = (optional || crate::attr::optional(target_attr)) && !source_attr.map(crate::attr::optional).unwrap_or(false);
744
+ let source_is_option = source_attr.map(crate::attr::optional).unwrap_or(false) || state_source_is_option(source, aggregate, optional);
745
+ let wrap = (optional || crate::attr::optional(target_attr)) && !source_is_option;
735
746
  if wrap {
736
747
  exemplar.render("mutation_set_wrapped", &[("tmpl_field", target_field.to_string()), ("tmpl_rhs_placeholder2()", rhs)])
737
748
  } else {
@@ -851,3 +862,37 @@ mod transition_tests {
851
862
  assert_eq!(transition_check_arg(Some(&transition)), "None");
852
863
  }
853
864
  }
865
+
866
+ #[cfg(test)]
867
+ mod state_source_tests {
868
+ use super::*;
869
+
870
+ fn aggregate() -> Json {
871
+ Json::parse(r#"{"name":"Page","attributes":[{"name":"body","type":"Body"},{"name":"draft_body","type":"Body","optional":true}]}"#).unwrap()
872
+ }
873
+
874
+ fn state(name: &str) -> Json {
875
+ Json::parse(&format!(r#"{{"kind":"state","name":"{name}"}}"#)).unwrap()
876
+ }
877
+
878
+ #[test]
879
+ fn a_state_source_in_an_all_optional_record_is_already_an_option() {
880
+ assert!(state_source_is_option(Some(&state("body")), &aggregate(), true));
881
+ }
882
+
883
+ #[test]
884
+ fn a_state_source_reads_an_option_where_it_is_declared_optional() {
885
+ assert!(state_source_is_option(Some(&state("draft_body")), &aggregate(), false));
886
+ }
887
+
888
+ #[test]
889
+ fn a_required_state_source_in_a_plain_record_is_not_an_option() {
890
+ assert!(!state_source_is_option(Some(&state("body")), &aggregate(), false));
891
+ }
892
+
893
+ #[test]
894
+ fn an_argument_source_is_never_a_state_option() {
895
+ let argument = Json::parse(r#"{"kind":"argument","name":"draft_body"}"#).unwrap();
896
+ assert!(!state_source_is_option(Some(&argument), &aggregate(), true));
897
+ }
898
+ }
@@ -70,6 +70,23 @@ pub fn dispatch_fn_name(cmd: &str) -> String {
70
70
  /// `hecks project_reserved_names` — the same tables `naming.rb` reads.
71
71
  pub use crate::reserved_names::{CARGO_RESERVED_DOMAIN_NAMES, RUST_KEYWORDS};
72
72
 
73
+ /// The modules every generated chapter declares itself, beside its aggregates' modules: the
74
+ /// sidecar `metadata`, the `registry` table, and the `merged` router. An aggregate whose
75
+ /// downcased name is one of these would declare the module twice and overwrite the file.
76
+ pub const CHAPTER_OWNED_MODULES: &[&str] = &["metadata", "registry", "merged"];
77
+
78
+ /// The module (and file stem) one aggregate's generated Rust lives in: its downcased name, or
79
+ /// `<name>_aggregate` when that name is one the chapter declares itself. A downcased name never
80
+ /// holds an underscore, so the suffixed form cannot meet another aggregate's module.
81
+ pub fn aggregate_module(name: &str) -> String {
82
+ let module = name.to_lowercase();
83
+ if CHAPTER_OWNED_MODULES.contains(&module.as_str()) {
84
+ format!("{module}_aggregate")
85
+ } else {
86
+ module
87
+ }
88
+ }
89
+
73
90
  /// `/\A[a-z_][a-z0-9_]*\z/` — a plain lowercase Rust identifier.
74
91
  fn plain_lower_identifier(name: &str) -> bool {
75
92
  let mut chars = name.chars();
@@ -336,6 +353,19 @@ mod tests {
336
353
  assert_eq!(scalar_to_value("String", &deref_scalar("String", "v")).as_deref(), Some("Value::Str(v.clone())"));
337
354
  }
338
355
 
356
+ #[test]
357
+ fn an_aggregate_named_for_a_chapter_module_gets_a_suffixed_module() {
358
+ assert_eq!(aggregate_module("Metadata"), "metadata_aggregate");
359
+ assert_eq!(aggregate_module("Registry"), "registry_aggregate");
360
+ assert_eq!(aggregate_module("Merged"), "merged_aggregate");
361
+ }
362
+
363
+ #[test]
364
+ fn any_other_aggregate_keeps_its_downcased_module() {
365
+ assert_eq!(aggregate_module("LineItem"), "lineitem");
366
+ assert_eq!(aggregate_module("MetadataEntry"), "metadataentry");
367
+ }
368
+
339
369
  #[test]
340
370
  fn refuses_an_aggregate_whose_module_name_is_a_rust_keyword() {
341
371
  let refusal = reserved_name_refusal("shop", "shop", &["Pizza", "Match", "Type"]).expect("refused");
@@ -1 +1 @@
1
- 3.9.0
1
+ 3.10.0
@@ -0,0 +1,443 @@
1
+ //! Lambda custom-runtime entry point (or a long-lived server under
2
+ //! HECKS_SERVE_MODE=1): boots Postgres/wasm state, then dispatches events.
3
+
4
+ use crate::{approval, auth, extension, ir, journal, lambda_client, log, mint, rate_limit, secrets, server, storage_shape};
5
+
6
+ use lambda_runtime::{service_fn, Error, LambdaEvent};
7
+ use std::path::PathBuf;
8
+ use std::sync::Arc;
9
+ use tokio::sync::Mutex;
10
+
11
+ /// Boots the host: secrets, Postgres, wasm, era minting, then the Lambda runtime loop (or the
12
+ /// long-lived server under HECKS_SERVE_MODE=1). Extensions are read from `extension::installed()`,
13
+ /// so a binary that wants its own must `extension::install` them before calling this.
14
+ pub async fn run() -> Result<(), Error> {
15
+ // HECKS_SERVE_MODE=1 switches the boot into the long-lived server
16
+ // loop below instead of the Lambda custom-runtime loop; every step
17
+ // from here through the era-minting sequence is shared, unchanged,
18
+ // by both modes.
19
+ let boot_started = std::time::Instant::now();
20
+ let serve_mode = std::env::var("HECKS_SERVE_MODE").as_deref() == Ok("1");
21
+
22
+ // HECKS_SESSION_COOKIE names the account cookie; refuse the boot
23
+ // here if it can't be written into a Set-Cookie header, rather
24
+ // than falling back silently at request time.
25
+ auth::resolve_account_cookie(std::env::var("HECKS_SESSION_COOKIE").ok().as_deref())?;
26
+
27
+ // DB_SECRET_ARN: the password is fetched from Secrets Manager here,
28
+ // at cold start, rather than trusted from a plaintext
29
+ // Environment.Variables value, which any principal with
30
+ // lambda:GetFunctionConfiguration could read regardless of the
31
+ // secret-at-rest protection its name suggests. Falls back to
32
+ // DATABASE_URL directly when DB_SECRET_ARN is absent (manual
33
+ // debugging over an SSM tunnel).
34
+ //
35
+ // The fetcher built here is reused below for GOOGLE_OAUTH_SECRET_ID
36
+ // and SESSION_SECRET_ARN too.
37
+ let secret_fetcher = secrets::AwsSecretFetcher::from_env().await;
38
+ let database_url = match std::env::var("DB_SECRET_ARN") {
39
+ Ok(secret_arn) => {
40
+ let db_host = std::env::var("DB_HOST").map_err(|_| "DB_HOST is required when DB_SECRET_ARN is set")?;
41
+ let db_name = std::env::var("DB_NAME").map_err(|_| "DB_NAME is required when DB_SECRET_ARN is set")?;
42
+ let secret_json = secret_fetcher
43
+ .fetch_secret_string(&secret_arn)
44
+ .await
45
+ .map_err(|e| format!("fetching DB_SECRET_ARN from Secrets Manager: {e:#}"))?;
46
+ // The user and port come from the secret too (a client sharing an instance logs in as
47
+ // its own role); the password goes in literal, since parse_database_url below never
48
+ // percent-decodes it.
49
+ secrets::database_url(&secret_json, &db_host, &db_name)?
50
+ }
51
+ Err(_) => std::env::var("DATABASE_URL")
52
+ .map_err(|_| "either DB_SECRET_ARN (+ DB_HOST/DB_NAME) or DATABASE_URL is required")?,
53
+ };
54
+
55
+ // GOOGLE_OAUTH_SECRET_ID/SESSION_SECRET_ARN: the same
56
+ // Secrets-Manager-at-cold-start approach as DB_SECRET_ARN above,
57
+ // applied to auth.rs's GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET and
58
+ // web.rs's SESSION_SECRET.
59
+ //
60
+ // Safety: this runs before any request is dispatched, so nothing
61
+ // else reads or writes the environment concurrently with these
62
+ // `set_var` calls.
63
+ if let Ok(oauth_secret_id) = std::env::var("GOOGLE_OAUTH_SECRET_ID") {
64
+ let secret_json = secret_fetcher
65
+ .fetch_secret_string(&oauth_secret_id)
66
+ .await
67
+ .map_err(|e| format!("fetching GOOGLE_OAUTH_SECRET_ID from Secrets Manager: {e:#}"))?;
68
+ let client_id = secrets::extract_field(&secret_json, "client_id")?;
69
+ let client_secret = secrets::extract_field(&secret_json, "client_secret")?;
70
+ unsafe {
71
+ std::env::set_var("GOOGLE_CLIENT_ID", client_id);
72
+ std::env::set_var("GOOGLE_CLIENT_SECRET", client_secret);
73
+ }
74
+ }
75
+ if let Ok(session_secret_arn) = std::env::var("SESSION_SECRET_ARN") {
76
+ let secret_json = secret_fetcher
77
+ .fetch_secret_string(&session_secret_arn)
78
+ .await
79
+ .map_err(|e| format!("fetching SESSION_SECRET_ARN from Secrets Manager: {e:#}"))?;
80
+ let session_secret = secrets::extract_field(&secret_json, "session_secret")?;
81
+ unsafe {
82
+ std::env::set_var("SESSION_SECRET", session_secret);
83
+ }
84
+ }
85
+ for extension in extension::installed() {
86
+ extension.boot_secrets(&secret_fetcher).await;
87
+ }
88
+ let wasm_path = PathBuf::from(
89
+ std::env::var("HECKS_WASM_PATH").unwrap_or_else(|_| "banking.wasm".to_string()),
90
+ );
91
+
92
+ // Must run before the Postgres connection opens -- `SET search_path`
93
+ // below has to run before `journal::ensure_schema` or anything else
94
+ // touches the connection, or unqualified names resolve against
95
+ // Postgres's default search_path instead of this domain's.
96
+ // HECKS_SCHEMA is optional.
97
+ let domain = std::env::var("HECKS_DOMAIN").map_err(|_| "HECKS_DOMAIN is required")?;
98
+ let schema = std::env::var("HECKS_SCHEMA").ok().filter(|s| !s.is_empty());
99
+ // Checkout on AWS keeps the business's payment keys in a named Secrets
100
+ // Manager secret; refuse before touching the database when none is named.
101
+ for extension in extension::installed() {
102
+ extension.boot_check(&domain)?;
103
+ }
104
+
105
+ // RDS refuses a plain NoTls connection by default and needs AWS's
106
+ // own RDS CA specifically, not a generic public bundle (see
107
+ // Cargo.toml).
108
+ let mut roots = rustls::RootCertStore::empty();
109
+ for cert in rustls_pemfile::certs(&mut include_bytes!("../rds-ca-bundle.pem").as_slice()) {
110
+ roots.add(cert.map_err(|e| format!("parsing rds-ca-bundle.pem: {e}"))?)?;
111
+ }
112
+ let tls_config = rustls::ClientConfig::builder()
113
+ .with_root_certificates(roots)
114
+ .with_no_client_auth();
115
+ let tls = tokio_postgres_rustls::MakeRustlsConnect::new(tls_config);
116
+ // Not tokio_postgres::connect(url, tls) -- that treats DATABASE_URL
117
+ // as a strict URI, and RDS-generated passwords can contain
118
+ // URI-reserved `?`/`#` that CloudFormation never percent-encodes.
119
+ let mut config = parse_database_url(&database_url)?;
120
+ // RDS requires TLS (rds-ca-bundle.pem); local Postgres over loopback
121
+ // can't be verified against the RDS CA, so localhost uses NoTls.
122
+ let local_postgres = database_url_is_local(&config);
123
+ if !local_postgres {
124
+ config.ssl_mode(tokio_postgres::config::SslMode::Require);
125
+ }
126
+ // NoTlsStream and RustlsStream can't unify in one branch, so the
127
+ // two paths are spawned separately.
128
+ let connect_phase = log::phase_with("db_connect", serde_json::json!({ "tls": !local_postgres }));
129
+ let client = if local_postgres {
130
+ let (client, connection) = config
131
+ .connect(tokio_postgres::NoTls)
132
+ .await
133
+ .map_err(|e| format!("connecting to Postgres via DATABASE_URL: {e:?}"))?;
134
+ tokio::spawn(async move {
135
+ if let Err(e) = connection.await {
136
+ eprintln!("postgres connection error: {e:#}");
137
+ }
138
+ });
139
+ client
140
+ } else {
141
+ let (client, connection) = config
142
+ .connect(tls)
143
+ .await
144
+ .map_err(|e| format!("connecting to Postgres via DATABASE_URL: {e:?}"))?;
145
+ tokio::spawn(async move {
146
+ if let Err(e) = connection.await {
147
+ eprintln!("postgres connection error: {e:#}");
148
+ }
149
+ });
150
+ client
151
+ };
152
+
153
+ connect_phase.end();
154
+
155
+ // Every unqualified table/view reference this binary issues
156
+ // resolves through search_path, so this is what makes a shared
157
+ // instance's per-domain schemas transparent to the rest of the
158
+ // binary.
159
+ //
160
+ // CREATE SCHEMA before SET search_path: setting search_path to a
161
+ // schema that doesn't exist yet succeeds in Postgres, so a missing
162
+ // schema only surfaces later as a confusing "no schema has been
163
+ // selected to create in" error from `ensure_schema`. Idempotent: an
164
+ // existing schema is the ordinary case on every boot after the first.
165
+ if let Some(schema) = &schema {
166
+ client
167
+ .batch_execute(&format!("CREATE SCHEMA IF NOT EXISTS {}", journal::quote_ident(schema)))
168
+ .await
169
+ .map_err(|e| format!("creating schema {schema:?}: {e:#}"))?;
170
+ client
171
+ .batch_execute(&format!("SET search_path TO {}", journal::quote_ident(schema)))
172
+ .await
173
+ .map_err(|e| format!("setting search_path to {schema:?}: {e:#}"))?;
174
+ }
175
+
176
+ let phase = log::phase("ensure_schema");
177
+ journal::ensure_schema(&client)
178
+ .await
179
+ .map_err(|e| format!("provisioning hecks_lambda_journal: {e:#}"))?;
180
+ phase.end();
181
+
182
+ let ir = ir::ir().ok_or("HECKS_IR_PATH is not set or unreadable — this binary needs its own domain's ir.json sidecar")?;
183
+ ir::refuse_unsupported_persistence_adapters(ir)?;
184
+ let my_hash = storage_shape::mint_hash(ir);
185
+ let my_label = storage_shape::mint_label(ir);
186
+
187
+ let mut aggregates: Vec<mint::Aggregate> = ir::lineage_capable_aggregates(ir)
188
+ .into_iter()
189
+ .map(|(qualified, storage_name)| {
190
+ let name = qualified.rsplit("::").next().unwrap_or(&qualified).to_string();
191
+ mint::Aggregate { name, storage_name }
192
+ })
193
+ .collect();
194
+ // A chapter that `provides "membership"` names who may sign in; that
195
+ // aggregate can be vendored, so it may be missing from this domain's
196
+ // lineage-capable list. Mint its head anyway for Google-auth to read.
197
+ if let Some(membership) = ir::membership_provider(ir) {
198
+ let name = membership.aggregate.rsplit("::").next().unwrap_or(&membership.aggregate).to_string();
199
+ let storage_name = journal::snake(&name);
200
+ if !aggregates.iter().any(|a| a.storage_name == storage_name) {
201
+ aggregates.push(mint::Aggregate { name, storage_name });
202
+ }
203
+ }
204
+
205
+ // The era-partitioned lineage subsystem is provisioned only when
206
+ // something needs it: a lineage-capable aggregate, or Google auth
207
+ // configured (ADR 0034 requires it for Member sessions either way).
208
+ let needs_lineage = !aggregates.is_empty() || std::env::var("GOOGLE_CLIENT_ID").is_ok();
209
+
210
+ let era: Option<i32> = if needs_lineage {
211
+ // Idempotent (gated by `provisioner?`); must run before the
212
+ // first `journal::held_eras` call, since `hecks_eras` doesn't
213
+ // exist yet on a truly fresh Postgres instance.
214
+ let phase = log::phase("ensure_base");
215
+ mint::ensure_base(&client, &domain).await.map_err(|e| format!("provisioning hecks_eras for {domain}: {e:#}"))?;
216
+ phase.end();
217
+
218
+ // Compares this binary's own shape hash (`storage_shape`, over
219
+ // `ir::ir()`) against held eras to decide the boot action: boot
220
+ // at a matching era, mint era 1 if the domain is new, or mint
221
+ // the next era if it has drifted -- refusing toward
222
+ // `hecks scaffold_translation` (no edge covers it) or
223
+ // `hecks audit_translation --approve` (needs review) otherwise.
224
+ let era_phase = log::phase("era_resolve");
225
+ let held = journal::held_eras(&client, &domain).await.map_err(|e| format!("checking hecks_eras for {domain}: {e:#}"))?;
226
+
227
+ // The decision itself is a pure, unit-tested function
228
+ // (`mint::decide_boot_action`); everything below just carries
229
+ // out whichever of its four outcomes came back.
230
+ let decision = mint::decide_boot_action(&held, &my_label);
231
+ era_phase.end_with(serde_json::json!({ "held_eras": held.len(), "decision": decision.name() }));
232
+ let ordinal: i32 = match decision {
233
+ // Adopting an existing era still provisions what this binary
234
+ // is about to write into, the same as minting does --
235
+ // skipping it would let a matched-era host write into
236
+ // head-snapshot tables that were never created.
237
+ mint::BootDecision::UseExisting { ordinal } => {
238
+ let phase = log::phase_with("adopt_head_snapshots", serde_json::json!({ "era": ordinal }));
239
+ mint::adopt_head_snapshots(&client, &domain, &aggregates, ordinal)
240
+ .await
241
+ .map_err(|e| format!("adopting era {ordinal} of {domain}: {e:#}"))?;
242
+ phase.end();
243
+ ordinal
244
+ }
245
+ mint::BootDecision::HoldFirst => {
246
+ let source_text =
247
+ ir.get("source_text").and_then(serde_json::Value::as_str).ok_or("ir.json is missing source_text — regenerate with hecks project_rust")?;
248
+ let phase = log::phase_with("hold_first", serde_json::json!({ "era": 1 }));
249
+ mint::hold_first(&client, &domain, source_text, ir, &aggregates, None).await.map_err(|e| format!("minting era 1 of {domain}: {e:#}"))?;
250
+ phase.end();
251
+ 1
252
+ }
253
+ mint::BootDecision::LatestUnnamed { ordinal } => {
254
+ return Err(format!(
255
+ "cannot boot: {domain}'s latest held era (ordinal {ordinal}) has no name yet -- this crate can't \
256
+ name it itself (no bluebook parser, ADR 0012's own boundary) -- boot Ruby once against this \
257
+ database to name it (Runtime::EraCheck's own lazy-naming path), then redeploy",
258
+ )
259
+ .into());
260
+ }
261
+ mint::BootDecision::Mint { ordinal, from_ordinal, from_label } => {
262
+ let mut labels: Vec<String> = held.iter().map(|held_era| held_era.label.clone().unwrap_or_default()).collect();
263
+ labels.push(my_label.clone());
264
+ let edges = mint::parse_edges(ir, &domain);
265
+ let chain = mint::edge_chain(&edges, &labels).map_err(|e| {
266
+ format!(
267
+ "cannot boot: {domain} has drifted from era {from_ordinal} ({from_label}) to a shape \
268
+ ({my_label}) no translation edge covers -- {e} -- run hecks scaffold_translation, review it \
269
+ with hecks audit_translation, then redeploy",
270
+ )
271
+ })?;
272
+
273
+ // Checks every edge in the chain with a compute/rekey
274
+ // rule, not just the newest -- a chain can carry more
275
+ // than one, matched against the raw ir.json edge
276
+ // (digest-relevant shape) by the same from/to pair.
277
+ let raw_edges = ir.get("translations").and_then(serde_json::Value::as_array).cloned().unwrap_or_default();
278
+ let committed_approvals = approval::committed(ir);
279
+ let approval_phase = log::phase_with("approval_check", serde_json::json!({ "edges": chain.len(), "committed": committed_approvals.len() }));
280
+ for edge in &chain {
281
+ let Some(raw_edge) = raw_edges.iter().find(|candidate| {
282
+ candidate.get("domain").and_then(serde_json::Value::as_str) == Some(domain.as_str())
283
+ && candidate.get("from").and_then(serde_json::Value::as_str) == Some(edge.from.as_str())
284
+ && candidate.get("to").and_then(serde_json::Value::as_str) == Some(edge.to.as_str())
285
+ }) else {
286
+ return Err(format!("cannot boot: {domain}'s edge {} -> {} vanished between parsing and approval-checking it", edge.from, edge.to).into());
287
+ };
288
+ approval::check(&client, &domain, raw_edge, ordinal, &committed_approvals).await.map_err(|e| format!("{e:#}"))?;
289
+ }
290
+ approval_phase.end();
291
+
292
+ // Runs before anything is minted, over the live compiled
293
+ // chain (plain SELECTs, never a persisted matview), so a
294
+ // refusal leaves no half-born era.
295
+ let watermarks: std::collections::HashMap<i32, Option<i64>> = held.iter().map(|held_era| (held_era.ordinal, held_era.watermark)).collect();
296
+ let audit_phase = log::phase_with("audit_before_mint", serde_json::json!({ "era": ordinal, "edges": chain.len() }));
297
+ mint::audit_before_mint(&client, &domain, ir, &aggregates, ordinal, &chain, &raw_edges, &watermarks)
298
+ .await
299
+ .map_err(|e| format!("{e:#}"))?;
300
+ audit_phase.end();
301
+
302
+ let held_text = ir.get("source_text").and_then(serde_json::Value::as_str).ok_or("ir.json is missing source_text — regenerate with hecks project_rust")?;
303
+ let mint_phase = log::phase_with("mint_era", serde_json::json!({ "era": ordinal, "edges": chain.len() }));
304
+ mint::mint_era(&client, &domain, ordinal, &my_hash, &my_label, held_text, &aggregates, &chain, None, &mint::lifecycle_defaults(ir))
305
+ .await
306
+ .map_err(|e| format!("minting era {ordinal} of {domain}: {e:#}"))?;
307
+ mint_phase.end();
308
+ ordinal
309
+ }
310
+ };
311
+ Some(ordinal)
312
+ } else {
313
+ None
314
+ };
315
+
316
+ // The mirrored set is the IR's own capable list, by qualified name
317
+ // -- never "everything this kernel can mutate". A domain that binds
318
+ // everything to plain `Postgres` mirrors nothing.
319
+ let mirrored = ir::mirrored_aggregates(ir);
320
+ let lineage_config = Arc::new(journal::LineageConfig { domain, era, mirrored: Some(mirrored) });
321
+
322
+ // Mutex, not a bare Arc<Client> -- dispatch::handle needs
323
+ // Client::transaction (&mut Client) to hold the advisory lock
324
+ // across the rehydrate-then-append sequence (see dispatch.rs).
325
+ let client = Arc::new(Mutex::new(client));
326
+ let wasm_path = Arc::new(wasm_path);
327
+ // One invoker for this process's lifetime -- credentials/region
328
+ // resolve once at boot, not per invocation. See lambda_client.rs:
329
+ // the compiled `.wasm` module can't dispatch this itself (no
330
+ // network inside that sandbox).
331
+ let invoker = Arc::new(lambda_client::AwsLambdaInvoker::from_env().await);
332
+
333
+ // `role` is optional, mirroring `args`: an absent key means no
334
+ // caller asserted a role, and `dispatch::handle`'s `check_role`
335
+ // stays on its unchecked path. Read by `server::dispatch_body` below.
336
+ if serve_mode {
337
+ log::info(
338
+ "boot",
339
+ serde_json::json!({ "mode": "serve", "domain": &lineage_config.domain, "era": &my_label, "boot_ms": boot_started.elapsed().as_millis() as u64 }),
340
+ );
341
+ let version = server::version_body(&my_label, &my_hash, std::env::var("HECKS_BUILD").ok().as_deref());
342
+ let limits = Arc::new(rate_limit::RateLimits::from_env());
343
+ let state = server::ServerState { client, wasm_path, lineage_config, invoker, limits };
344
+ return server::serve(state, version).await;
345
+ }
346
+
347
+ lambda_runtime::run(service_fn(move |event: LambdaEvent<serde_json::Value>| {
348
+ let client = Arc::clone(&client);
349
+ let wasm_path = Arc::clone(&wasm_path);
350
+ let lineage_config = Arc::clone(&lineage_config);
351
+ let invoker = Arc::clone(&invoker);
352
+ async move {
353
+ let (body, _context) = event.into_parts();
354
+ server::dispatch_body(body, &client, &wasm_path, &lineage_config, invoker.as_ref()).await
355
+ }
356
+ }))
357
+ .await
358
+ }
359
+
360
+ // Parses `postgres://user:password@host:port/db` without treating it
361
+ // as a URI -- an RDS/Aurora auto-generated password can contain
362
+ // `?`/`#`, which a strict URI parser would treat as delimiters.
363
+ fn parse_database_url(url: &str) -> Result<tokio_postgres::Config, String> {
364
+ let rest = url
365
+ .strip_prefix("postgres://")
366
+ .or_else(|| url.strip_prefix("postgresql://"))
367
+ .ok_or_else(|| format!("DATABASE_URL doesn't start with postgres:// or postgresql://"))?;
368
+
369
+ let (authority, db_and_query) = rest
370
+ .split_once('/')
371
+ .ok_or_else(|| "DATABASE_URL has no '/' separating host from database name".to_string())?;
372
+ let dbname = db_and_query.split(['?', '#']).next().unwrap_or(db_and_query);
373
+
374
+ let (credentials, host_and_port) = match authority.rsplit_once('@') {
375
+ Some((credentials, host_and_port)) => (Some(credentials), host_and_port),
376
+ None => (None, authority),
377
+ };
378
+ let (host, port) = match host_and_port.rsplit_once(':') {
379
+ Some((host, port)) => {
380
+ let port: u16 = port
381
+ .parse()
382
+ .map_err(|e| format!("DATABASE_URL's port {port:?} isn't a valid number: {e}"))?;
383
+ (host, port)
384
+ }
385
+ None => (host_and_port, 5432),
386
+ };
387
+
388
+ let mut config = tokio_postgres::Config::new();
389
+ config.host(host).port(port).dbname(dbname);
390
+ if let Some(credentials) = credentials {
391
+ match credentials.split_once(':') {
392
+ Some((user, password)) => {
393
+ config.user(user).password(password);
394
+ }
395
+ None => {
396
+ config.user(credentials);
397
+ }
398
+ }
399
+ }
400
+ Ok(config)
401
+ }
402
+
403
+ fn database_url_is_local(config: &tokio_postgres::Config) -> bool {
404
+ use tokio_postgres::config::Host;
405
+ config.get_hosts().iter().any(|host| match host {
406
+ Host::Tcp(name) => name == "localhost" || name == "127.0.0.1" || name == "::1",
407
+ _ => false,
408
+ })
409
+ }
410
+
411
+ #[cfg(test)]
412
+ mod tests {
413
+ use super::*;
414
+
415
+ #[test]
416
+ fn parses_a_password_with_uri_reserved_characters() {
417
+ let config = parse_database_url(
418
+ "postgres://postgres:T3st$Pa?#ss*>Word@db.example.com:5432/pizzas",
419
+ )
420
+ .expect("should parse despite ?/# in the password");
421
+ assert_eq!(config.get_hosts().len(), 1);
422
+ assert_eq!(config.get_ports(), &[5432]);
423
+ assert_eq!(config.get_user(), Some("postgres"));
424
+ assert_eq!(config.get_dbname(), Some("pizzas"));
425
+ }
426
+
427
+ #[test]
428
+ fn parses_a_plain_password_too() {
429
+ let config = parse_database_url("postgres://postgres:plainpassword@localhost:5432/pizzas")
430
+ .expect("should parse a password with no special characters");
431
+ assert_eq!(config.get_user(), Some("postgres"));
432
+ assert_eq!(config.get_dbname(), Some("pizzas"));
433
+ }
434
+
435
+ #[test]
436
+ fn parses_a_local_url_with_no_user_or_port() {
437
+ let config = parse_database_url("postgres://localhost/app_development")
438
+ .expect("should parse a peer-auth local URL");
439
+ assert_eq!(config.get_dbname(), Some("app_development"));
440
+ assert_eq!(config.get_ports(), &[5432]);
441
+ assert!(database_url_is_local(&config));
442
+ }
443
+ }
data/rust/host/src/lib.rs CHANGED
@@ -4,6 +4,7 @@
4
4
  pub mod api;
5
5
  pub mod approval;
6
6
  pub mod auth;
7
+ pub mod boot;
7
8
  pub mod checkout;
8
9
  pub mod commerce_ir;
9
10
  pub mod dispatch;