hecks 3.1.1 → 3.1.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e8bfb40ad9e9760533d77e179154821128fee28930877e35118b5e87c5c32731
4
- data.tar.gz: 49fd0e562e846d8633c11101d7b11c6d751089c8ca0f281907f1c669e1d19438
3
+ metadata.gz: 508adf3c37fdb512fe3fa7ba61d7f614742a76b750f0072a363bec69bc99cefd
4
+ data.tar.gz: 825f357d3d68ccab74ba071fc4ab3abc412ffc38458968d9f038a769e2039382
5
5
  SHA512:
6
- metadata.gz: e584bed9d83942763f583530d44910d6a988c7b55af6a4177f257ba017562957f373ea7ea726340b7ae845e2811d7217665ab6815991ae9cffb0e6cb437928c2
7
- data.tar.gz: 6cac69c95c6dedc28736d5401d64a6597327c27b8d3ed1909c225ad6191c4cb0cac929ac9f3896f5c648c890d75b10d6d4b3d5431498e82e560bd682f2dc312d
6
+ metadata.gz: e55bd4e1b03b8399b7dc189bee0cd126a00ec315eed8c1060cd0dd237c443c61af26d6005af246c1855f54f683d710646bf7b05fbb068335c14fbb01eb5cdcef
7
+ data.tar.gz: 300db7e2fd71722c2627fa1bcc96c992776d87e796db3cdc83570eb8b48f2200c05def67bea1f116a30506c613f69e44a4573d36f3e36fd5bc394687b694855f
@@ -79,7 +79,7 @@ module Hecks
79
79
  :infra_name, :stack_prefix, :instance_type, :volume_gb, :swap_gb, :database_class,
80
80
  :storage_gb, :backup_days, :snapshots_keep, :database_name, :engine_version,
81
81
  :containers, :routes, :default_container, :origin_header, :origin_secret,
82
- :secret_prefixes, :tunnel, :tunnel_service, :proxy_image, :task_definition,
82
+ :secret_prefixes, :writable_secrets, :tunnel, :tunnel_service, :proxy_image, :task_definition,
83
83
  :migration, :s3_buckets, keyword_init: true
84
84
  ) do
85
85
  # @return [String] the CloudFormation stack that holds the database
@@ -119,6 +119,7 @@ module Hecks
119
119
  containers: containers, routes: read_routes(s.fetch(:routes, []), containers),
120
120
  default_container: read_default(s[:default_container], containers), origin_header: header,
121
121
  origin_secret: secret, secret_prefixes: read_prefixes(s, infra_name),
122
+ writable_secrets: read_writable_secrets(s),
122
123
  tunnel: tunnel, tunnel_service: tunnel_service,
123
124
  proxy_image: check(:proxy_image, s.fetch(:proxy_image, PROXY_IMAGE), IMAGE),
124
125
  task_definition: task_definition, migration: read_migration(s[:migration], database_name),
@@ -283,6 +284,12 @@ module Hecks
283
284
  list.map { |p| check(:secret_prefixes, p, SECRET_NAME) }
284
285
  end
285
286
 
287
+ # Secrets the box may overwrite, such as one an admin page stores a key in. Production
288
+ # only: a rehearsal box never changes a secret.
289
+ def read_writable_secrets(settings)
290
+ Array(settings.fetch(:writable_secrets, [])).map { |name| check(:writable_secrets, name, SECRET_NAME) }
291
+ end
292
+
286
293
  def string_map(key, value, key_pattern, value_pattern)
287
294
  raise ArgumentError, "#{key}: expected a hash" unless value.is_a?(Hash)
288
295
 
@@ -104,6 +104,7 @@ Resources:
104
104
  Resource:
105
105
  - !Ref DbSecretArn
106
106
  @@SECRET_RESOURCES@@
107
+ @@WRITABLE_SECRETS@@
107
108
  @@S3_POLICY@@
108
109
 
109
110
  BoxProfile:
@@ -18,6 +18,19 @@ DB_SECRET=$(out "$RDS_STACK" DbSecretArn)
18
18
  ACCOUNT=$(aws sts get-caller-identity --query Account --output text)
19
19
 
20
20
  WORK=$(mktemp -d); trap 'rm -rf "$WORK"' EXIT
21
+
22
+ # SMOKE_LISTENER=1 (a rehearsal box only) mounts a loopback HTTPS site on 127.0.0.1:8443 that adds the
23
+ # origin secret, so a browser-driven smoke run can reach the box through an SSM port-forward without the
24
+ # CDN. TLS because a site that treats a request carrying the origin secret as https expects an https
25
+ # Origin on every form post.
26
+ cat > "$WORK/smoke.caddy" <<'SMOKE_EOF'
27
+ https://127.0.0.1:8443 {
28
+ tls internal
29
+ reverse_proxy 127.0.0.1:80 {
30
+ @@SMOKE_HEADER@@
31
+ }
32
+ }
33
+ SMOKE_EOF
21
34
  ( cd "$WORK" && bash "$HERE/render-compose.sh" "$DB_HOST" "$DB_SECRET" "$@" )
22
35
 
23
36
  # Run a script on the box over SSM, wait for it, print its output, return its status.
@@ -38,12 +51,14 @@ run_on_box() {
38
51
  B64() { base64 < "$1" | tr -d '\n'; }
39
52
  ROLL=$(jq -n --arg compose "$(B64 "$WORK/compose.json")" --arg secrets "$(B64 "$WORK/secrets.json")" \
40
53
  --arg caddy "$(B64 "$HERE/Caddyfile")" --arg fetch "$(B64 "$HERE/fetch-secrets.sh")" \
54
+ --arg smokefile "$(B64 "$WORK/smoke.caddy")" --arg smoke "${SMOKE_LISTENER:-}" \
41
55
  --arg registry "$ACCOUNT.dkr.ecr.$REGION.amazonaws.com" --arg dir "$DIR" '
42
56
  {commands: ["cloud-init status --wait >/dev/null 2>&1 || true",
43
57
  "set -e", "mkdir -p \($dir)/caddy-extra && cd \($dir)", "umask 077",
44
58
  "echo \($compose) | base64 -d > compose.json; echo \($secrets) | base64 -d > secrets.json",
45
59
  "echo \($caddy) | base64 -d > Caddyfile; echo \($fetch) | base64 -d > fetch-secrets.sh",
46
60
  "bash fetch-secrets.sh",
61
+ (if $smoke == "1" then "echo \($smokefile) | base64 -d > caddy-extra/smoke.caddy" else "rm -f caddy-extra/smoke.caddy" end),
47
62
  "aws ecr get-login-password --region '"$REGION"' | docker login --username AWS --password-stdin \($registry) >/dev/null",
48
63
  "docker compose -f compose.json pull --quiet",
49
64
  "docker compose -f compose.json up -d --remove-orphans",
@@ -196,7 +196,8 @@ module Hecks
196
196
  "VOLUME_GB" => plan.volume_gb.to_s, "SNAPSHOTS_KEEP" => plan.snapshots_keep.to_s,
197
197
  "AMI_PARAMETER" => ami_parameter(plan.instance_type),
198
198
  "COMPUTE_DOMAIN" => compute_domain(region),
199
- "SECRET_RESOURCES" => secret_resources(plan), "TUNNEL_EGRESS" => tunnel_egress(plan),
199
+ "SECRET_RESOURCES" => secret_resources(plan), "WRITABLE_SECRETS" => writable_secrets(plan),
200
+ "TUNNEL_EGRESS" => tunnel_egress(plan),
200
201
  "SWAP_COMMANDS" => swap_commands(plan), "ECR_REPOSITORIES" => ecr_repositories(plan),
201
202
  "ECR_OUTPUTS" => ecr_outputs(plan), "S3_POLICY" => s3_policy(plan))
202
203
  end
@@ -261,6 +262,28 @@ module Hecks
261
262
  end.join.chomp
262
263
  end
263
264
 
265
+ # The statement that lets a production box overwrite the secrets named as writable.
266
+ #
267
+ # @param plan [Settings::Plan] the resolved settings
268
+ # @return [String] a policy statement for the role, or nothing when none are declared
269
+ def writable_secrets(plan)
270
+ return "" if plan.writable_secrets.empty?
271
+
272
+ arns = plan.writable_secrets.map { |name| name.end_with?("*") ? name : "#{name}-*" }.uniq
273
+ resources = arns.map do |pattern|
274
+ "#{' ' * 20}- !Sub \"arn:${AWS::Partition}:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:#{pattern}\"\n"
275
+ end.join
276
+ <<~YAML.chomp
277
+ #{' ' * 14}- !If
278
+ #{' ' * 16}- IsProduction
279
+ #{' ' * 16}- Effect: Allow
280
+ #{' ' * 18}Action: secretsmanager:PutSecretValue
281
+ #{' ' * 18}Resource:
282
+ #{resources.chomp}
283
+ #{' ' * 16}- !Ref AWS::NoValue
284
+ YAML
285
+ end
286
+
264
287
  # @param plan [Settings::Plan] the resolved settings
265
288
  # @return [String] the outbound rules a Cloudflare Tunnel sidecar needs, or nothing
266
289
  def tunnel_egress(plan)
@@ -437,7 +460,14 @@ module Hecks
437
460
  template("deploy-box.sh.tmpl", "STACK" => plan.infra_name, "BOX_STACK" => plan.box_stack,
438
461
  "RDS_STACK" => plan.rds_stack, "REGION" => region,
439
462
  "DIR" => "/opt/#{plan.infra_name}", "HEALTH_CHECKS" => health_checks(plan),
440
- "USAGE" => deploy_usage(plan))
463
+ "SMOKE_HEADER" => smoke_header(plan), "USAGE" => deploy_usage(plan))
464
+ end
465
+
466
+ # @param plan [Settings::Plan] the resolved settings
467
+ # @return [String] the line that adds the origin secret to the smoke listener's requests, or
468
+ # nothing when the world has no origin guard
469
+ def smoke_header(plan)
470
+ plan.origin_header ? "\t\theader_up #{plan.origin_header} {$ORIGIN_SECRET}" : ""
441
471
  end
442
472
 
443
473
  # @param plan [Settings::Plan] the resolved settings
@@ -8,11 +8,15 @@
8
8
  # machine. It replaces any path to a working copy: ROOT below points the
9
9
  # Makefile's build and mint-era steps at it. Override HECKS_VERSION to build
10
10
  # from another release, or HECKS_ROOT to use a checkout you manage yourself.
11
+ #
12
+ # HECKS_VERSION=edge is the one exception to "pinned": it follows the newest commit on main,
13
+ # is fetched afresh on every build, and prints the commit it got.
11
14
 
12
15
  HECKS_VERSION ?= @@RELEASE@@
13
16
  HECKS_SOURCE ?= @@SOURCE@@
14
17
  HECKS_CACHE_DIR ?= @@CACHE_DIR@@
15
18
  HECKS_ROOT ?= $(HECKS_CACHE_DIR)/hecks-$(HECKS_VERSION)
19
+ HECKS_TAG := $(if $(filter edge,$(HECKS_VERSION)),edge,v$(HECKS_VERSION))
16
20
  ROOT := $(HECKS_ROOT)
17
21
  SERVICE ?= @@SERVICE@@
18
22
  URL ?= @@URL@@
@@ -22,20 +26,32 @@ URL ?= @@URL@@
22
26
  build: hecks-release
23
27
 
24
28
  # Fetches the pinned release's tag into HECKS_ROOT unless it is already there,
25
- # and refuses a checkout that is not exactly that tag.
29
+ # and refuses a checkout that is not exactly that tag. The edge tag moves, so it is
30
+ # fetched afresh on every build and its commit is printed, not checked.
26
31
  hecks-release:
32
+ ifeq ($(HECKS_VERSION),edge)
33
+ @mkdir -p "$(HECKS_CACHE_DIR)"; \
34
+ if [ -d "$(HECKS_ROOT)/.git" ]; then \
35
+ git -C "$(HECKS_ROOT)" fetch --quiet --depth 1 origin "+refs/tags/edge:refs/tags/edge" && \
36
+ git -C "$(HECKS_ROOT)" checkout --quiet --detach edge; \
37
+ else \
38
+ git clone --quiet --depth 1 --branch edge "$(HECKS_SOURCE)" "$(HECKS_ROOT)"; \
39
+ fi; \
40
+ echo "hecks edge at $$(git -C "$(HECKS_ROOT)" rev-parse --short HEAD) in $(HECKS_ROOT)"
41
+ else
27
42
  @if [ -f "$(HECKS_ROOT)/exe/hecks" ]; then \
28
43
  echo "hecks v$(HECKS_VERSION) is already at $(HECKS_ROOT)"; \
29
44
  else \
30
45
  echo "Fetching hecks v$(HECKS_VERSION) from $(HECKS_SOURCE) into $(HECKS_ROOT)..."; \
31
46
  mkdir -p "$(HECKS_CACHE_DIR)" && \
32
- git clone --quiet --depth 1 --branch "v$(HECKS_VERSION)" "$(HECKS_SOURCE)" "$(HECKS_ROOT)"; \
47
+ git clone --quiet --depth 1 --branch "$(HECKS_TAG)" "$(HECKS_SOURCE)" "$(HECKS_ROOT)"; \
33
48
  fi
34
49
  @tag="$$(git -C "$(HECKS_ROOT)" describe --tags --exact-match 2>/dev/null)"; \
35
- if [ "$$tag" != "v$(HECKS_VERSION)" ]; then \
36
- echo "$(HECKS_ROOT) is at '$$tag', not v$(HECKS_VERSION). Remove it and re-run, or set HECKS_ROOT."; \
50
+ if [ "$$tag" != "$(HECKS_TAG)" ]; then \
51
+ echo "$(HECKS_ROOT) is at '$$tag', not $(HECKS_TAG). Remove it and re-run, or set HECKS_ROOT."; \
37
52
  exit 1; \
38
53
  fi
54
+ endif
39
55
 
40
56
  # Pushes one container's already-built local image under a fresh tag and rolls
41
57
  # it out. Build the image first (docker-build); see deploy-service.sh.
@@ -12,6 +12,8 @@ module Hecks
12
12
  REF = %r{\A[\w./-]+\z}
13
13
  ERA = /\A[0-9a-zA-Z._-]+\z/
14
14
  RELEASE = /\Av?\d+\.\d+\.\d+([.-][0-9A-Za-z.-]+)?\z/
15
+ # The moving tag on main; the one release name that is not a version.
16
+ EDGE = "edge".freeze
15
17
  SOURCE = %r{\A[\w.@:/~-]+\z}
16
18
  MAKE_PATH = /\A[^\s#]+\z/
17
19
 
@@ -43,6 +45,8 @@ module Hecks
43
45
  release = fetch(:hecks_release, nil)
44
46
  raise ArgumentError, missing_release_message unless release
45
47
 
48
+ return EDGE if release.to_s == EDGE
49
+
46
50
  check(:hecks_release, release.to_s, RELEASE).delete_prefix("v")
47
51
  end
48
52
 
@@ -63,6 +67,8 @@ module Hecks
63
67
  end
64
68
 
65
69
  so the domain image is built from a fixed Hecks release, not from a checkout on one machine.
70
+ `hecks_release "edge"` follows the newest commit on main instead: each build fetches it
71
+ afresh and prints the commit it got, so a build is not reproducible from the name alone.
66
72
  MSG
67
73
  end
68
74
 
data/lib/hecks/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  module Hecks
2
2
  # Read directly by the gemspec, so this file must never require the
3
3
  # rest of the gem (its dependencies aren't resolved yet at that point).
4
- VERSION = "3.1.1".freeze
4
+ VERSION = "3.1.2".freeze
5
5
  end
@@ -444,14 +444,7 @@ pub fn emit_command(exemplar: &Exemplar, command: &Json, aggregate: &Json, domai
444
444
  .collect();
445
445
 
446
446
  let transition = mutations::lifecycle_transition_for(command, aggregate);
447
- let transition_arg = match &transition {
448
- Some(t) => format!(
449
- "Some(crate::kernel::TransitionCheck {{ field: {}, from_states: &[{}] }})",
450
- naming::ruby_inspect_string(&t.field),
451
- t.from_states.iter().map(|s| naming::ruby_inspect_string(s)).collect::<Vec<_>>().join(", ")
452
- ),
453
- None => "None".to_string(),
454
- };
447
+ let transition_arg = mutations::transition_check_arg(transition.as_ref());
455
448
 
456
449
  let mutations_list = command.get("mutations").map(Json::each).unwrap_or(&[]);
457
450
  // A `delegate` mutation is rendered by `delegation_of`, which overwrites `mutation_lines`;
@@ -772,14 +765,7 @@ fn delegation_of(exemplar: &Exemplar, command: &Json, aggregate: &Json, value_ob
772
765
  })
773
766
  .collect();
774
767
  let transition = mutations::lifecycle_transition_for(target, entity);
775
- let transition_arg = match &transition {
776
- Some(t) => format!(
777
- "Some(crate::kernel::TransitionCheck {{ field: {}, from_states: &[{}] }})",
778
- naming::ruby_inspect_string(&t.field),
779
- t.from_states.iter().map(|s| naming::ruby_inspect_string(s)).collect::<Vec<_>>().join(", ")
780
- ),
781
- None => "None".to_string(),
782
- };
768
+ let transition_arg = mutations::transition_check_arg(transition.as_ref());
783
769
  let mut mutation_lines: Vec<String> = target
784
770
  .get("mutations")
785
771
  .map(Json::each)
@@ -896,14 +882,7 @@ pub fn emit_entity_command(
896
882
  .collect();
897
883
 
898
884
  let transition = mutations::lifecycle_transition_for(command, entity);
899
- let transition_arg = match &transition {
900
- Some(t) => format!(
901
- "Some(crate::kernel::TransitionCheck {{ field: {}, from_states: &[{}] }})",
902
- naming::ruby_inspect_string(&t.field),
903
- t.from_states.iter().map(|s| naming::ruby_inspect_string(s)).collect::<Vec<_>>().join(", ")
904
- ),
905
- None => "None".to_string(),
906
- };
885
+ let transition_arg = mutations::transition_check_arg(transition.as_ref());
907
886
 
908
887
  let mutations_list = command.get("mutations").map(Json::each).unwrap_or(&[]);
909
888
  let mut mutation_lines: Vec<String> = mutations_list.iter().map(|m| mutations::emit_mutation_line(exemplar, m, entity, command, value_objects_by_name, false)).collect();
@@ -1063,14 +1042,7 @@ pub fn emit_nested_entity_command(
1063
1042
 
1064
1043
  // The nested entity's lifecycle, as in `emit_entity_command`.
1065
1044
  let transition = mutations::lifecycle_transition_for(command, nested);
1066
- let transition_arg = match &transition {
1067
- Some(t) => format!(
1068
- "Some(crate::kernel::TransitionCheck {{ field: {}, from_states: &[{}] }})",
1069
- naming::ruby_inspect_string(&t.field),
1070
- t.from_states.iter().map(|s| naming::ruby_inspect_string(s)).collect::<Vec<_>>().join(", ")
1071
- ),
1072
- None => "None".to_string(),
1073
- };
1045
+ let transition_arg = mutations::transition_check_arg(transition.as_ref());
1074
1046
 
1075
1047
  let mutations_list = command.get("mutations").map(Json::each).unwrap_or(&[]);
1076
1048
  let mut mutation_lines: Vec<String> = mutations_list.iter().map(|m| mutations::emit_mutation_line(exemplar, m, nested, command, value_objects_by_name, false)).collect();
@@ -392,6 +392,23 @@ pub struct Transition {
392
392
  pub field: String,
393
393
  pub to_state: String,
394
394
  pub from_states: Vec<String>,
395
+ /// A row with no `from:` admits every state, so no check is emitted for the command.
396
+ pub unconstrained: bool,
397
+ }
398
+
399
+ /// The `Option<TransitionCheck>` expression a dispatch function is generated with.
400
+ ///
401
+ /// `None` covers both "no transition" and an unconstrained one; an empty `from_states` slice
402
+ /// would refuse every state instead of admitting every state.
403
+ pub fn transition_check_arg(transition: Option<&Transition>) -> String {
404
+ match transition {
405
+ Some(t) if !t.unconstrained => format!(
406
+ "Some(crate::kernel::TransitionCheck {{ field: {}, from_states: &[{}] }})",
407
+ naming::ruby_inspect_string(&t.field),
408
+ t.from_states.iter().map(|s| naming::ruby_inspect_string(s)).collect::<Vec<_>>().join(", ")
409
+ ),
410
+ _ => "None".to_string(),
411
+ }
395
412
  }
396
413
 
397
414
  /// Collapses a command's transition rows into the shape `TransitionCheck` wants.
@@ -417,19 +434,25 @@ pub fn lifecycle_transition_for(command: &Json, aggregate: &Json) -> Option<Tran
417
434
  }
418
435
  }
419
436
  let field = lifecycle.get("field").and_then(Json::as_str).unwrap_or("").to_string();
420
- return Some(Transition { field, to_state: String::new(), from_states });
437
+ return Some(Transition { field, to_state: String::new(), from_states, unconstrained: false });
421
438
  }
422
439
 
423
440
  let field = lifecycle.get("field").and_then(Json::as_str).unwrap_or("").to_string();
424
441
  let to_state = rows[0].get("to_state").map(Json::to_s).unwrap_or_default();
425
442
  let mut from_states: Vec<String> = Vec::new();
443
+ let mut unconstrained = false;
426
444
  for row in &rows {
427
- let from = row.get("from_state").map(Json::to_s).unwrap_or_default();
428
- if !from_states.contains(&from) {
429
- from_states.push(from);
445
+ match row.get_raw("from_state") {
446
+ None | Some(Json::Null) => unconstrained = true,
447
+ Some(from) => {
448
+ let from = from.to_s();
449
+ if !from_states.contains(&from) {
450
+ from_states.push(from);
451
+ }
452
+ }
430
453
  }
431
454
  }
432
- Some(Transition { field, to_state, from_states })
455
+ Some(Transition { field, to_state, from_states, unconstrained })
433
456
  }
434
457
 
435
458
  /// Right-hand side for a `set`, coercing the source into the target attribute's declared type.
@@ -782,3 +805,49 @@ fn emit_mutation_line_body(
782
805
  other => panic!("unsupported mutation op {other:?} — command_skip_reason should have caught this"),
783
806
  }
784
807
  }
808
+
809
+ #[cfg(test)]
810
+ mod transition_tests {
811
+ use super::*;
812
+
813
+ fn aggregate(rows: &str) -> Json {
814
+ Json::parse(&format!(
815
+ r#"{{"name":"Account","lifecycle":{{"field":"status","default":"draft","transitions":[{rows}]}}}}"#
816
+ ))
817
+ .unwrap()
818
+ }
819
+
820
+ fn command(name: &str) -> Json {
821
+ Json::parse(&format!(r#"{{"name":"{name}"}}"#)).unwrap()
822
+ }
823
+
824
+ #[test]
825
+ fn a_transition_without_from_admits_every_state() {
826
+ let account = aggregate(r#"{"command":"Open","to_state":"open","from_state":null}"#);
827
+ let transition = lifecycle_transition_for(&command("Open"), &account).unwrap();
828
+ assert!(transition.unconstrained);
829
+ assert_eq!(transition.to_state, "open");
830
+ assert_eq!(transition_check_arg(Some(&transition)), "None");
831
+ }
832
+
833
+ #[test]
834
+ fn a_transition_with_from_checks_that_state() {
835
+ let account = aggregate(r#"{"command":"Close","to_state":"closed","from_state":"open"}"#);
836
+ let transition = lifecycle_transition_for(&command("Close"), &account).unwrap();
837
+ assert!(!transition.unconstrained);
838
+ assert_eq!(
839
+ transition_check_arg(Some(&transition)),
840
+ r#"Some(crate::kernel::TransitionCheck { field: "status", from_states: &["open"] })"#
841
+ );
842
+ }
843
+
844
+ #[test]
845
+ fn one_unconstrained_row_among_constrained_ones_admits_every_state() {
846
+ let account = aggregate(
847
+ r#"{"command":"Reopen","to_state":"open","from_state":"closed"},
848
+ {"command":"Reopen","to_state":"open","from_state":null}"#,
849
+ );
850
+ let transition = lifecycle_transition_for(&command("Reopen"), &account).unwrap();
851
+ assert_eq!(transition_check_arg(Some(&transition)), "None");
852
+ }
853
+ }
@@ -1 +1 @@
1
- 3.1.1
1
+ 3.1.2
@@ -0,0 +1,39 @@
1
+ use super::*;
2
+
3
+ #[test]
4
+ fn cents_render_as_dollars_with_two_places() {
5
+ assert_eq!(dollars(10800), "$108.00");
6
+ assert_eq!(dollars(14805), "$148.05");
7
+ assert_eq!(dollars(5), "$0.05");
8
+ }
9
+
10
+ #[test]
11
+ fn the_built_in_receipt_names_the_event_the_first_name_and_the_amount() {
12
+ let body = built_in_body(Some("Ada"), "Yogadelics", Some(10800));
13
+ assert!(body.starts_with("Hi Ada,"));
14
+ assert!(body.contains("You're registered for Yogadelics."));
15
+ assert!(body.contains("$108.00"));
16
+ assert_eq!(receipt_subject("Yogadelics"), "You're registered for Yogadelics");
17
+ }
18
+
19
+ #[test]
20
+ fn a_built_in_receipt_without_a_name_or_amount_still_reads_cleanly() {
21
+ let body = built_in_body(None, "Yogadelics", None);
22
+ assert!(body.starts_with("Hi,"));
23
+ assert!(!body.contains("payment of"));
24
+ }
25
+
26
+ #[test]
27
+ fn the_sites_finished_email_is_read() {
28
+ let answer = serde_json::json!({"subject": "You're registered for Yogadelics", "body": "Hi Ada,\n\nWhere: Somewhere\n"});
29
+ assert_eq!(
30
+ site_email_from_json(&answer),
31
+ Some(SiteEmail { subject: "You're registered for Yogadelics".into(), body: "Hi Ada,\n\nWhere: Somewhere\n".into() })
32
+ );
33
+ }
34
+
35
+ #[test]
36
+ fn an_incomplete_answer_from_the_site_falls_back_to_the_built_in_wording() {
37
+ assert_eq!(site_email_from_json(&serde_json::json!({"error": "no such session"})), None);
38
+ assert_eq!(site_email_from_json(&serde_json::json!({"subject": "Hi", "body": " "})), None);
39
+ }
@@ -0,0 +1,122 @@
1
+ // The email a registrant gets once their payment has gone through: what they
2
+ // booked and what they paid. Best-effort, like every other mail the host
3
+ // sends: the payment stands whatever delivery does.
4
+
5
+ use super::instances_for;
6
+ use crate::dispatch;
7
+ use crate::ir::PaymentsProvider;
8
+ use crate::journal::LineageConfig;
9
+ use crate::resend::{Email, Mailer};
10
+ use serde_json::{json, Value};
11
+ use std::path::Path;
12
+ use tokio::sync::Mutex;
13
+ use tokio_postgres::Client;
14
+
15
+ #[cfg(test)]
16
+ mod tests;
17
+
18
+ fn dollars(cents: i64) -> String {
19
+ format!("${}.{:02}", cents / 100, cents % 100)
20
+ }
21
+
22
+ fn receipt_subject(event_name: &str) -> String {
23
+ format!("You're registered for {event_name}")
24
+ }
25
+
26
+ /// The subject and body the site built from the wording an editor keeps in
27
+ /// the CMS (when, where and how-to-prepare are filled in there: the venue lives
28
+ /// only in the CMS).
29
+ #[derive(Debug, PartialEq)]
30
+ struct SiteEmail {
31
+ subject: String,
32
+ body: String,
33
+ }
34
+
35
+ fn site_email_from_json(body: &Value) -> Option<SiteEmail> {
36
+ let text = |key: &str| body.get(key).and_then(Value::as_str).filter(|s| !s.trim().is_empty()).map(String::from);
37
+ Some(SiteEmail { subject: text("subject")?, body: text("body")? })
38
+ }
39
+
40
+ /// Asks the site for the finished email (signed, short-lived, bound to this
41
+ /// one event). Best effort: any failure means the host's own built-in wording
42
+ /// goes out instead, so a receipt is always sent.
43
+ async fn site_email(event_slug: &str, first_name: Option<&str>, amount_cents: Option<i64>) -> Option<SiteEmail> {
44
+ let secret = std::env::var("SESSION_SECRET").ok().filter(|s| !s.is_empty())?;
45
+ let token = crate::auth::purpose_token(&secret, "confirmation-email", json!({ "event": event_slug }), 300);
46
+ let mut url = reqwest::Url::parse(&format!("{}/api/confirmation-email", super::newsletter_send::site_url())).ok()?;
47
+ {
48
+ let mut query = url.query_pairs_mut();
49
+ query.append_pair("event", event_slug);
50
+ if let Some(name) = first_name {
51
+ query.append_pair("first_name", name);
52
+ }
53
+ if let Some(cents) = amount_cents {
54
+ query.append_pair("amount_cents", &cents.to_string());
55
+ }
56
+ }
57
+ let response = reqwest::Client::new().get(url).header("x-internal-auth", token).timeout(std::time::Duration::from_secs(8)).send().await.ok()?;
58
+ if !response.status().is_success() {
59
+ return None;
60
+ }
61
+ site_email_from_json(&response.json::<Value>().await.ok()?)
62
+ }
63
+
64
+ /// The host's own wording, used when the site cannot be asked.
65
+ fn built_in_body(first_name: Option<&str>, event_name: &str, amount_cents: Option<i64>) -> String {
66
+ let greeting = first_name.map(|name| format!("Hi {name},")).unwrap_or_else(|| "Hi,".to_string());
67
+ let paid = amount_cents.map(|cents| format!(" We received your payment of {}.", dollars(cents))).unwrap_or_default();
68
+ format!("{greeting}\n\nYou're registered for {event_name}.{paid}\n\nYour seat is held. If anything changes, just reply to this email.\n")
69
+ }
70
+
71
+ /// Mails the receipt for `reference` if its Payment has succeeded. A missing
72
+ /// mailer, registrant address or event is skipped quietly.
73
+ pub(super) async fn send_receipt(reference: &str, client: &Mutex<Client>, wasm_path: &Path, config: &LineageConfig, payments: &PaymentsProvider) {
74
+ let mailer = match Mailer::from_env() {
75
+ Ok(Some(mailer)) => mailer,
76
+ Ok(None) => return,
77
+ Err(e) => {
78
+ eprintln!("registration receipt: {e}");
79
+ return;
80
+ }
81
+ };
82
+ let Ok(read) = dispatch::read(client, wasm_path).await else {
83
+ return;
84
+ };
85
+ let binding = crate::ir::registrations_binding(&config.domain);
86
+ let registrations = instances_for(&read, &binding.registration_prefix());
87
+ let Some((_, registration)) = registrations.iter().find(|(id, _)| id == reference) else {
88
+ return;
89
+ };
90
+ let payment_instances = instances_for(&read, &payments.instance_prefix());
91
+ let payment = payment_instances.iter().find(|(id, _)| id == reference).map(|(_, p)| p);
92
+ if payment.and_then(|p| p.get("status")).and_then(|s| s.as_str()) != Some("succeeded") {
93
+ return;
94
+ }
95
+ let attendee = registration.get("attendee").cloned().unwrap_or_else(|| serde_json::json!({}));
96
+ let Some(to) = attendee.get("email").and_then(|v| v.as_str()).filter(|s| !s.is_empty()) else {
97
+ return;
98
+ };
99
+ let events = instances_for(&read, &binding.event_prefix());
100
+ let event_name = registration
101
+ .get("event_slug")
102
+ .and_then(|v| v.as_str())
103
+ .and_then(|slug| events.iter().find(|(id, _)| id == slug))
104
+ .and_then(|(_, e)| e.get("name"))
105
+ .and_then(|n| n.get("value"))
106
+ .and_then(|v| v.as_str())
107
+ .unwrap_or("your event");
108
+ let amount = payment.and_then(|p| p.get("amount")).and_then(|a| a.get("cents")).and_then(Value::as_i64);
109
+ let first_name = attendee.get("first_name").and_then(|v| v.as_str()).filter(|s| !s.is_empty());
110
+ let from_site = match registration.get("event_slug").and_then(|v| v.as_str()) {
111
+ Some(slug) => site_email(slug, first_name, amount).await,
112
+ None => None,
113
+ };
114
+ let (subject, body) = match from_site {
115
+ Some(email) => (email.subject, email.body),
116
+ None => (receipt_subject(event_name), built_in_body(first_name, event_name, amount)),
117
+ };
118
+ let delivery = mailer.deliver(&Email { to, subject: &subject, body: &body, unsubscribe_url: None }).await;
119
+ if !delivery.ok {
120
+ eprintln!("registration receipt: not delivered: {}", delivery.reason.unwrap_or_default());
121
+ }
122
+ }
@@ -367,6 +367,12 @@ pub(crate) async fn registration_complete_route(
367
367
  }
368
368
  }
369
369
 
370
+ // Only a payment that settled just now earns a receipt; a repeat click was
371
+ // refused above as already settled.
372
+ if outcome == "succeeded" && outcome_result.accepted {
373
+ super::registration_receipt::send_receipt(registration_id, client, wasm_path, config, payments).await;
374
+ }
375
+
370
376
  let read = match dispatch::read(client, wasm_path).await {
371
377
  Ok(r) => r,
372
378
  Err(e) => return respond(500, "text/plain", &format!("{e:#}")),
@@ -675,8 +681,14 @@ pub(crate) async fn webhook_route(
675
681
  // A refusal here (e.g. a redelivered webhook — Stripe's delivery
676
682
  // is at-least-once) is a benign no-op: the payment already holds
677
683
  // the right status, and 200 tells Stripe's retry logic to stop.
678
- if let Err(e) = dispatch::handle_routed(client, wasm_path, verb, json!(reference), facts, None, config, invoker).await {
679
- return respond(500, "text/plain", &format!("{e:#}"));
684
+ match dispatch::handle_routed(client, wasm_path, verb, json!(reference), facts, None, config, invoker).await {
685
+ Err(e) => return respond(500, "text/plain", &format!("{e:#}")),
686
+ // Only the delivery that settles the payment sends the receipt;
687
+ // a redelivery is refused and stays quiet.
688
+ Ok(settled) if settled.accepted && event_type == "checkout.session.completed" => {
689
+ super::registration_receipt::send_receipt(&reference, client, wasm_path, config, payments).await;
690
+ }
691
+ Ok(_) => {}
680
692
  }
681
693
  }
682
694
  }
data/rust/host/src/web.rs CHANGED
@@ -18,6 +18,7 @@ use tokio_postgres::Client;
18
18
 
19
19
  mod newsletter;
20
20
  mod newsletter_send;
21
+ mod registration_receipt;
21
22
  mod registrations;
22
23
 
23
24
  use registrations::payments_routes;
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hecks
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.1.1
4
+ version: 3.1.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Chris Young
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-10-04 00:00:00.000000000 Z
11
+ date: 2026-10-05 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: prism
@@ -910,6 +910,8 @@ files:
910
910
  - rust/host/src/web/newsletter/template.rs
911
911
  - rust/host/src/web/newsletter/tests.rs
912
912
  - rust/host/src/web/newsletter_send.rs
913
+ - rust/host/src/web/registration_receipt.rs
914
+ - rust/host/src/web/registration_receipt/tests.rs
913
915
  - rust/host/src/web/registrations.rs
914
916
  - rust/lsp/.gitignore
915
917
  - rust/lsp/Cargo.lock