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 +4 -4
- data/lib/hecks/projections/deploy/box/settings.rb +8 -1
- data/lib/hecks/projections/deploy/box/templates/box.yaml.tmpl +1 -0
- data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +15 -0
- data/lib/hecks/projections/deploy/box.rb +32 -2
- data/lib/hecks/projections/deploy/scripts/hosting.mk.tmpl +20 -4
- data/lib/hecks/projections/deploy/scripts/settings.rb +6 -0
- data/lib/hecks/version.rb +1 -1
- data/rust/codegen/src/commands.rs +4 -32
- data/rust/codegen/src/mutations.rs +74 -5
- data/rust/host/HECKS_RELEASE +1 -1
- data/rust/host/src/web/registration_receipt/tests.rs +39 -0
- data/rust/host/src/web/registration_receipt.rs +122 -0
- data/rust/host/src/web/registrations.rs +14 -2
- data/rust/host/src/web.rs +1 -0
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 508adf3c37fdb512fe3fa7ba61d7f614742a76b750f0072a363bec69bc99cefd
|
|
4
|
+
data.tar.gz: 825f357d3d68ccab74ba071fc4ab3abc412ffc38458968d9f038a769e2039382
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
|
@@ -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), "
|
|
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 "
|
|
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" != "
|
|
36
|
-
echo "$(HECKS_ROOT) is at '$$tag', not
|
|
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
|
@@ -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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
428
|
-
|
|
429
|
-
|
|
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
|
+
}
|
data/rust/host/HECKS_RELEASE
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
3.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", ¢s.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
|
-
|
|
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
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.
|
|
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-
|
|
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
|