hecks 3.12.0 → 3.14.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 (72) hide show
  1. checksums.yaml +4 -4
  2. data/examples/pizzas/bluebook/pizzas.bluebook +4 -4
  3. data/lib/hecks/adapters/driven/script_runner.adapter +3 -0
  4. data/lib/hecks/adapters/driven/script_runner.rb +93 -0
  5. data/lib/hecks/adapters/driven.rb +1 -0
  6. data/lib/hecks/bluebook/dsl/attribute_collector.rb +15 -5
  7. data/lib/hecks/bluebook/meta_validator/syntax_boot.rb +16 -12
  8. data/lib/hecks/bluebook/meta_validator.rb +28 -21
  9. data/lib/hecks/bluebook/named_pattern.rb +33 -0
  10. data/lib/hecks/bluebook.rb +1 -0
  11. data/lib/hecks/cli/domain_stub.rb +1 -1
  12. data/lib/hecks/cli/interview_aggregate.rb +4 -4
  13. data/lib/hecks/deploy/bluebook/deploy.bluebook +35 -35
  14. data/lib/hecks/framework/bluebook/change.behaviors +30 -0
  15. data/lib/hecks/framework/bluebook/change.bluebook +162 -0
  16. data/lib/hecks/framework/bluebook/change.hecksagon +21 -0
  17. data/lib/hecks/framework/change.oidc.json +34 -0
  18. data/lib/hecks/framework/console_settings.oidc.json +103 -0
  19. data/lib/hecks/framework/governance.oidc.json +28 -0
  20. data/lib/hecks/framework/identity.oidc.json +18 -0
  21. data/lib/hecks/framework/privacy.oidc.json +30 -0
  22. data/lib/hecks/fuzzing/domain_generator/block_rendering.rb +1 -1
  23. data/lib/hecks/gate/stages.yml +3 -0
  24. data/lib/hecks/grammar/translation.oidc.json +48 -0
  25. data/lib/hecks/hecks/adapters/codebase/test_runner.rb +12 -1
  26. data/lib/hecks/hecks/adapters/deploy_toolchain.rb +2 -1
  27. data/lib/hecks/hecks/codebase.bluebook +37 -37
  28. data/lib/hecks/hecks/custodian.bluebook +14 -14
  29. data/lib/hecks/hecks/hecks.bluebook +2 -2
  30. data/lib/hecks/hecks/rehearsal.bluebook +3 -3
  31. data/lib/hecks/hecks/tooling.bluebook +22 -22
  32. data/lib/hecks/language/bluebook/aggregate.bluebook +1 -0
  33. data/lib/hecks/language/bluebook/command.bluebook +1 -0
  34. data/lib/hecks/language/bluebook/entity.bluebook +1 -0
  35. data/lib/hecks/language/bluebook/query.bluebook +1 -0
  36. data/lib/hecks/language/bluebook/shape.bluebook +8 -7
  37. data/lib/hecks/language/hecksagon/port_operation.bluebook +1 -0
  38. data/lib/hecks/projections/deploy/fargate/cdn/lines.rb +12 -1
  39. data/lib/hecks/projections/site/admin_cms/templates/membership.rb +10 -7
  40. data/lib/hecks/projections/site/edge/behaviours.rb +10 -6
  41. data/lib/hecks/projections/site/edge/checks.rb +2 -0
  42. data/lib/hecks/projections/site/edge/function_checks.rb +104 -0
  43. data/lib/hecks/projections/site/edge/function_code.rb +86 -0
  44. data/lib/hecks/projections/site/edge/redirect_host.js.tmpl +65 -0
  45. data/lib/hecks/projections/site/edge.rb +35 -9
  46. data/lib/hecks/projections/site/site_cdn.rb +21 -7
  47. data/lib/hecks/projections/site/table/row_builder.rb +12 -2
  48. data/lib/hecks/projections/site/table.rb +4 -2
  49. data/lib/hecks/quality_control/quality_control.bluebook +51 -51
  50. data/lib/hecks/site/bluebook/site.bluebook +21 -6
  51. data/lib/hecks/sme/bluebook/sme.bluebook +12 -12
  52. data/lib/hecks/tickets/bluebook/tickets.bluebook +3 -3
  53. data/lib/hecks/tools/oidc_manifests.rb +44 -6
  54. data/lib/hecks/tools/site_routes/templates.rb +13 -9
  55. data/lib/hecks/version.rb +1 -1
  56. data/prebuilt/{d74a00e996222c1d8b676d5959092effc46b4dc59e0555f3bf9554ac58e3094a.marshal → 1a6f596be124228be93eba1d86dbc9d2933190fa389b284331241cb9f27115a0.marshal} +0 -0
  57. data/prebuilt/{9ee54e2f0d4d6d3fe046827779b221e02de665fba911a048ae5dd8b8fbfb7d90.marshal → a883a7e568667aa5ac904c82daff356bdf9e996252a36af6def4a4a43efb967e.marshal} +0 -0
  58. data/prebuilt/verdicts-872993b1aa26d73fe48f28251374b8585fed145d02aab42be0f00de386b6ff80.json +1 -0
  59. data/rust/host/HECKS_RELEASE +1 -1
  60. data/rust/host/src/auth.rs +58 -0
  61. data/rust/host/src/payments/roles.rs +159 -0
  62. data/rust/host/src/payments/tests.rs +25 -0
  63. data/rust/host/src/payments.rs +20 -11
  64. data/rust/host/src/platform_signin/session.rs +1 -1
  65. data/rust/parser/src/build/mod.rs +1 -0
  66. data/rust/parser/src/build/named_pattern.rs +23 -0
  67. data/rust/parser/src/keywords.rs +6 -0
  68. data/rust/parser/src/parse/mod.rs +22 -1
  69. metadata +24 -7
  70. data/prebuilt/verdicts-df19f35868d3284a70f55e42dcef87ef4e65e6a678e5135f72e1f6cff4c1357b.json +0 -1
  71. /data/lib/hecks/framework/{oidc.json → compliance.oidc.json} +0 -0
  72. /data/lib/hecks/grammar/{oidc.json → expression.oidc.json} +0 -0
@@ -10,19 +10,19 @@ Hecks.bluebook "Hecks" do
10
10
  identified_by :domain
11
11
 
12
12
  value_object "DomainPath" do
13
- attribute :value, String, pattern: '[^\t\n\r]'
13
+ attribute :value, String, matches: :not_empty
14
14
 
15
15
  invariant("a domain is named by its path") { !value.to_s.empty? }
16
16
  end
17
17
 
18
18
  value_object "ChapterName" do
19
- attribute :value, String, pattern: '[^ \t\n\r]'
19
+ attribute :value, String, matches: :present
20
20
 
21
21
  invariant("a chapter is named") { !value.to_s.empty? }
22
22
  end
23
23
 
24
24
  value_object "AggregateName" do
25
- attribute :value, String, pattern: '[^ \t\n\r]'
25
+ attribute :value, String, matches: :present
26
26
 
27
27
  invariant("an aggregate is named") { !value.to_s.empty? }
28
28
  end
@@ -128,13 +128,13 @@ Hecks.bluebook "Hecks" do
128
128
  identified_by :run
129
129
 
130
130
  value_object "RunKey" do
131
- attribute :value, String, pattern: '[^ \t\n\r]'
131
+ attribute :value, String, matches: :present
132
132
 
133
133
  invariant("a run is named") { !value.to_s.empty? }
134
134
  end
135
135
 
136
136
  value_object "DomainList" do
137
- attribute :value, String, pattern: '[^\t\n\r]'
137
+ attribute :value, String, matches: :not_empty
138
138
  end
139
139
 
140
140
  value_object "ProfileName" do
@@ -254,7 +254,7 @@ Hecks.bluebook "Hecks" do
254
254
 
255
255
  # A directory: the source repository (`from`), or the project the files land in (`root`).
256
256
  value_object "Location" do
257
- attribute :value, String, pattern: '[^\t\n\r]'
257
+ attribute :value, String, matches: :not_empty
258
258
  end
259
259
 
260
260
  value_object "Report" do
@@ -383,18 +383,18 @@ Hecks.bluebook "Hecks" do
383
383
  identified_by :run
384
384
 
385
385
  value_object "RegistryKey" do
386
- attribute :value, String, pattern: '[^ \t\n\r]'
386
+ attribute :value, String, matches: :present
387
387
 
388
388
  invariant("a release is named") { !value.to_s.empty? }
389
389
  end
390
390
 
391
391
  value_object "PackageName" do
392
- attribute :value, String, pattern: '^[a-z][a-z0-9_]*$'
392
+ attribute :value, String, matches: :snake_name
393
393
  end
394
394
 
395
395
  # The registry repository: the current directory when absent.
396
396
  value_object "Location" do
397
- attribute :value, String, pattern: '[^\t\n\r]'
397
+ attribute :value, String, matches: :not_empty
398
398
  end
399
399
 
400
400
  value_object "Report" do
@@ -520,14 +520,14 @@ Hecks.bluebook "Hecks" do
520
520
  identified_by :run
521
521
 
522
522
  value_object "LaunchKey" do
523
- attribute :value, String, pattern: '[^ \t\n\r]'
523
+ attribute :value, String, matches: :present
524
524
 
525
525
  invariant("a launch is named") { !value.to_s.empty? }
526
526
  end
527
527
 
528
528
  # Domain directories under the project, comma separated.
529
529
  value_object "DomainList" do
530
- attribute :value, String, pattern: '[^\t\n\r]'
530
+ attribute :value, String, matches: :not_empty
531
531
  end
532
532
 
533
533
  value_object "Switch" do
@@ -546,12 +546,12 @@ Hecks.bluebook "Hecks" do
546
546
 
547
547
  # Where the new domain goes; the domain's snake-cased name under the current directory when absent.
548
548
  value_object "TargetDir" do
549
- attribute :value, String, pattern: '[^\t\n\r]'
549
+ attribute :value, String, matches: :not_empty
550
550
  end
551
551
 
552
552
  # Who is interviewed; asked for at the terminal when absent.
553
553
  value_object "ExpertName" do
554
- attribute :value, String, pattern: '[^\t\n\r]'
554
+ attribute :value, String, matches: :not_empty
555
555
  end
556
556
 
557
557
  value_object "Note" do
@@ -724,7 +724,7 @@ Hecks.bluebook "Hecks" do
724
724
  identified_by :run
725
725
 
726
726
  value_object "BuildKey" do
727
- attribute :value, String, pattern: '[^ \t\n\r]'
727
+ attribute :value, String, matches: :present
728
728
 
729
729
  invariant("a build is named") { !value.to_s.empty? }
730
730
  end
@@ -737,18 +737,18 @@ Hecks.bluebook "Hecks" do
737
737
 
738
738
  # A generated module: the directory name a domain was built as.
739
739
  value_object "ModuleName" do
740
- attribute :value, String, pattern: '^[a-z][a-z0-9_]*$'
740
+ attribute :value, String, matches: :snake_name
741
741
  end
742
742
 
743
743
  # A JSON file of steps for a domain to replay.
744
744
  value_object "ScriptPath" do
745
- attribute :value, String, pattern: '[^\t\n\r]'
745
+ attribute :value, String, matches: :not_empty
746
746
  end
747
747
 
748
748
  # What a build is replayed through: `native`, `build` (the domain's own feature), a `.wasm`
749
749
  # module, or a binary.
750
750
  value_object "Artifact" do
751
- attribute :value, String, pattern: '[^ \t\n\r]'
751
+ attribute :value, String, matches: :present
752
752
  end
753
753
 
754
754
  # A Rust target triple: `<arch>-<vendor>-<os>` with an optional `-<env>`. The toolchain
@@ -759,7 +759,7 @@ Hecks.bluebook "Hecks" do
759
759
 
760
760
  # The directory a host build stages its files in.
761
761
  value_object "StagePath" do
762
- attribute :value, String, pattern: '[^\t\n\r]'
762
+ attribute :value, String, matches: :not_empty
763
763
  end
764
764
 
765
765
  value_object "Count" do
@@ -1014,14 +1014,14 @@ Hecks.bluebook "Hecks" do
1014
1014
  identified_by :run
1015
1015
 
1016
1016
  value_object "FuzzKey" do
1017
- attribute :value, String, pattern: '[^ \t\n\r]'
1017
+ attribute :value, String, matches: :present
1018
1018
 
1019
1019
  invariant("a sweep is named") { !value.to_s.empty? }
1020
1020
  end
1021
1021
 
1022
1022
  # A domain directory; a sweep with none covers every domain of a hecks checkout.
1023
1023
  value_object "DomainPath" do
1024
- attribute :value, String, pattern: '[^\t\n\r]'
1024
+ attribute :value, String, matches: :not_empty
1025
1025
 
1026
1026
  invariant("a domain is named by its path") { !value.to_s.empty? }
1027
1027
  end
@@ -1054,7 +1054,7 @@ Hecks.bluebook "Hecks" do
1054
1054
  end
1055
1055
 
1056
1056
  value_object "BinaryPath" do
1057
- attribute :value, String, pattern: '[^\t\n\r]'
1057
+ attribute :value, String, matches: :not_empty
1058
1058
  end
1059
1059
 
1060
1060
  value_object "Format" do
@@ -1062,7 +1062,7 @@ Hecks.bluebook "Hecks" do
1062
1062
  end
1063
1063
 
1064
1064
  value_object "OutputPath" do
1065
- attribute :value, String, pattern: '[^\t\n\r]'
1065
+ attribute :value, String, matches: :not_empty
1066
1066
  end
1067
1067
 
1068
1068
  value_object "Report" do
@@ -440,6 +440,7 @@ Hecks.bluebook "Bluebook" do
440
440
  member keyword: "attribute", context: "Aggregate", at: "", named: "default", kind: "literal", required: "false", fills: "default"
441
441
  member keyword: "attribute", context: "Aggregate", at: "", named: "optional", kind: "flag", required: "false", fills: "optional"
442
442
  member keyword: "attribute", context: "Aggregate", at: "", named: "pattern", kind: "text", required: "false", fills: "pattern"
443
+ member keyword: "attribute", context: "Aggregate", at: "", named: "matches", kind: "symbol", required: "false", fills: "matches"
443
444
  member keyword: "attribute", context: "Aggregate", at: "", named: "admits", kind: "text", required: "false", fills: "admits"
444
445
  member keyword: "attribute", context: "Aggregate", at: "", named: "one_of", kind: "list", required: "false", fills: "one_of"
445
446
  member keyword: "invariant", context: "Aggregate", at: "1", named: "", kind: "text", required: "true", fills: "description"
@@ -368,6 +368,7 @@ Hecks.bluebook "Bluebook" do
368
368
  member keyword: "attribute", context: "Command", at: "", named: "default", kind: "literal", required: "false", fills: "default"
369
369
  member keyword: "attribute", context: "Command", at: "", named: "optional", kind: "flag", required: "false", fills: "optional"
370
370
  member keyword: "attribute", context: "Command", at: "", named: "pattern", kind: "text", required: "false", fills: "pattern"
371
+ member keyword: "attribute", context: "Command", at: "", named: "matches", kind: "symbol", required: "false", fills: "matches"
371
372
  member keyword: "attribute", context: "Command", at: "", named: "admits", kind: "text", required: "false", fills: "admits"
372
373
  member keyword: "attribute", context: "Command", at: "", named: "one_of", kind: "list", required: "false", fills: "one_of"
373
374
  # `reason` is required in practice; CommandBuilder#corrects_impl refuses its absence.
@@ -319,6 +319,7 @@ Hecks.bluebook "Bluebook" do
319
319
  member keyword: "attribute", context: "Entity", at: "", named: "default", kind: "literal", required: "false", fills: "default"
320
320
  member keyword: "attribute", context: "Entity", at: "", named: "optional", kind: "flag", required: "false", fills: "optional"
321
321
  member keyword: "attribute", context: "Entity", at: "", named: "pattern", kind: "text", required: "false", fills: "pattern"
322
+ member keyword: "attribute", context: "Entity", at: "", named: "matches", kind: "symbol", required: "false", fills: "matches"
322
323
  member keyword: "attribute", context: "Entity", at: "", named: "admits", kind: "text", required: "false", fills: "admits"
323
324
  member keyword: "attribute", context: "Entity", at: "", named: "one_of", kind: "list", required: "false", fills: "one_of"
324
325
  member keyword: "reference_to", context: "Entity", at: "1", named: "", kind: "constant", required: "true", fills: "type"
@@ -229,6 +229,7 @@ Hecks.bluebook "Bluebook" do
229
229
  member keyword: "attribute", context: "Query", at: "", named: "default", kind: "literal", required: "false", fills: "default"
230
230
  member keyword: "attribute", context: "Query", at: "", named: "optional", kind: "flag", required: "false", fills: "optional"
231
231
  member keyword: "attribute", context: "Query", at: "", named: "pattern", kind: "text", required: "false", fills: "pattern"
232
+ member keyword: "attribute", context: "Query", at: "", named: "matches", kind: "symbol", required: "false", fills: "matches"
232
233
  member keyword: "attribute", context: "Query", at: "", named: "admits", kind: "text", required: "false", fills: "admits"
233
234
  member keyword: "attribute", context: "Query", at: "", named: "one_of", kind: "list", required: "false", fills: "one_of"
234
235
  member keyword: "reference_to", context: "Query", at: "1", named: "", kind: "constant", required: "true", fills: "type"
@@ -19,14 +19,14 @@ Hecks.bluebook "Bluebook" do
19
19
  end
20
20
 
21
21
  value_object "ValueObjectName" do
22
- attribute :value, String, pattern: '[^ \t\n\r]'
22
+ attribute :value, String, matches: :present
23
23
  invariant("a value object is named") { !value.to_s.empty? }
24
24
  end
25
25
 
26
26
  value_object "ShapeField" do
27
- attribute :name, String, pattern: '[^ \t\n\r]'
28
- attribute :type, String, pattern: '[^ \t\n\r]'
29
- attribute :list, String, pattern: '[^ \t\n\r]'
27
+ attribute :name, String, matches: :present
28
+ attribute :type, String, matches: :present
29
+ attribute :list, String, matches: :present
30
30
  attribute :optional, String, optional: true
31
31
  attribute :pattern, String, optional: true
32
32
  # optional: a declaration with no default has none
@@ -40,8 +40,8 @@ Hecks.bluebook "Bluebook" do
40
40
  end
41
41
 
42
42
  value_object "Assertion" do
43
- attribute :description, String, pattern: '[^ \t\n\r]'
44
- attribute :canonical, String, pattern: '[^ \t\n\r]'
43
+ attribute :description, String, matches: :present
44
+ attribute :canonical, String, matches: :present
45
45
  invariant("an assertion description is present") { !description.to_s.empty? }
46
46
  invariant("an assertion canonical form is present") { !canonical.to_s.empty? }
47
47
  end
@@ -59,7 +59,7 @@ Hecks.bluebook "Bluebook" do
59
59
  end
60
60
 
61
61
  value_object "Pair" do
62
- attribute :key, String, pattern: '[^ \t\n\r]'
62
+ attribute :key, String, matches: :present
63
63
  attribute :value, String
64
64
  # Keys must be named; values may be empty or numeric.
65
65
  invariant("a pair key is present") { !key.to_s.empty? }
@@ -230,6 +230,7 @@ Hecks.bluebook "Bluebook" do
230
230
  member keyword: "attribute", context: "ValueObject", at: "", named: "default", kind: "literal", required: "false", fills: "default"
231
231
  member keyword: "attribute", context: "ValueObject", at: "", named: "optional", kind: "flag", required: "false", fills: "optional"
232
232
  member keyword: "attribute", context: "ValueObject", at: "", named: "pattern", kind: "text", required: "false", fills: "pattern"
233
+ member keyword: "attribute", context: "ValueObject", at: "", named: "matches", kind: "symbol", required: "false", fills: "matches"
233
234
  member keyword: "attribute", context: "ValueObject", at: "", named: "admits", kind: "text", required: "false", fills: "admits"
234
235
  member keyword: "attribute", context: "ValueObject", at: "", named: "one_of", kind: "list", required: "false", fills: "one_of"
235
236
  member keyword: "invariant", context: "ValueObject", at: "1", named: "", kind: "text", required: "true", fills: "description"
@@ -80,6 +80,7 @@ Hecks.bluebook "Hecksagon" do
80
80
  member keyword: "attribute", context: "PortOperation", at: "", named: "default", kind: "literal", required: "false", fills: "default"
81
81
  member keyword: "attribute", context: "PortOperation", at: "", named: "optional", kind: "flag", required: "false", fills: "optional"
82
82
  member keyword: "attribute", context: "PortOperation", at: "", named: "pattern", kind: "text", required: "false", fills: "pattern"
83
+ member keyword: "attribute", context: "PortOperation", at: "", named: "matches", kind: "symbol", required: "false", fills: "matches"
83
84
  member keyword: "attribute", context: "PortOperation", at: "", named: "admits", kind: "text", required: "false", fills: "admits"
84
85
  member keyword: "attribute", context: "PortOperation", at: "", named: "one_of", kind: "list", required: "false", fills: "one_of"
85
86
  member keyword: "emits", context: "PortOperation", at: "1", named: "", kind: "text", required: "true", fills: "emits"
@@ -13,7 +13,8 @@ module Hecks
13
13
  # (`:origin`,
14
14
  # `:path` for an item, `:viewer_protocol`, `:methods`, `:compress`, `:cache_policy`,
15
15
  # `:origin_request_policy`), and optionally `:response_headers_policy`, each policy as
16
- # `[id_or_intrinsic, comment_or_nil]`
16
+ # `[id_or_intrinsic, comment_or_nil]`, and `:edge_function`, the logical id of a
17
+ # viewer-request function
17
18
  # @param base [String] the indentation of `key`, or of the list item's dash
18
19
  # @return [Array<String>] the lines
19
20
  def behavior_lines(key, entry, base)
@@ -91,6 +92,16 @@ module Hecks
91
92
  lines << "#{pad}Compress: #{entry[:compress]}" if entry.key?(:compress)
92
93
  lines << policy_line(pad, "CachePolicyId", entry[:cache_policy])
93
94
  lines.concat(optional_policy_lines(entry, pad))
95
+ lines.concat(function_lines(entry, pad))
96
+ end
97
+
98
+ # The viewer-request function a behaviour names (`:edge_function`, the logical id of an
99
+ # `AWS::CloudFront::Function` in the same template), if any.
100
+ def function_lines(entry, pad)
101
+ return [] unless entry[:edge_function]
102
+
103
+ ["#{pad}FunctionAssociations:", "#{pad} - EventType: viewer-request",
104
+ "#{pad} FunctionARN: !GetAtt #{entry[:edge_function]}.FunctionARN"]
94
105
  end
95
106
 
96
107
  def optional_policy_lines(entry, pad)
@@ -15,10 +15,12 @@ module Hecks
15
15
  // Signing in (endpoints/sso.ts) proved the person was admitted then; this asks again, so that
16
16
  // disabling or removing someone locks them out on their next request, not when their session runs out.
17
17
  //
18
- // The question is the host's own account route, which answers only for a person who holds a role
19
- // and is not disabled, asked with a short account token signed by the secret the host checks (the
20
- // content system's AUTH_SECRET is the host's SESSION_SECRET). The answer is remembered for a minute
21
- // per email so every admin request does not cost a round trip.
18
+ // The question is the host's members list, which the host answers (403) only to a person holding
19
+ // one of the admin roles, and the list's own row for the caller must hold such a role and not be
20
+ // disabled. It is asked with a short account token signed by the secret the host checks (the content
21
+ // system's AUTH_SECRET is the host's SESSION_SECRET). The answer is remembered for a minute per
22
+ // email so every admin request does not cost a round trip. Being admitted to the site is not enough:
23
+ // a member with no admin role is refused here, as the site's own admin gate refuses them.
22
24
  import { accountToken, resolveAccountCookieName } from "@hecks/client";
23
25
 
24
26
  const DEFAULT_URL = __HOST_DEFAULT__;
@@ -38,7 +40,7 @@ module Hecks
38
40
 
39
41
  export interface MembershipOptions {
40
42
  url?: string;
41
- /** The host route asked: the account route (any admitted person) by default, the members list for admin roles only. */
43
+ /** The host route asked: the account route (any admitted person) by default; `adminCheck` asks the members list, for admin roles only. */
42
44
  path?: string;
43
45
  /** Decides from the host's answer; the default is any 2xx. */
44
46
  accept?: (res: Response, email: string) => Promise<boolean>;
@@ -83,8 +85,6 @@ module Hecks
83
85
  };
84
86
  }
85
87
 
86
- export const membership = membershipCheck();
87
-
88
88
  /** The roles the host's own admin gate passes. */
89
89
  export const ADMIN_ROLES = __ROLES__;
90
90
 
@@ -106,6 +106,9 @@ module Hecks
106
106
  path: __MEMBERS_PATH__,
107
107
  accept: async (res, email) => holdsAdminRole(await res.json().catch(() => null), email),
108
108
  });
109
+
110
+ /** The check the sign-in endpoint and the session strategy use: admin roles only, never a plain member. */
111
+ export const membership = adminCheck();
109
112
  TS
110
113
  end
111
114
  end
@@ -10,7 +10,8 @@ module Hecks
10
10
  # compression give it. A route needs none of its own when the behaviour that would match its
11
11
  # path anyway (the nearest broader route's, else the default one) is the same. The rest keep
12
12
  # the order the table declares, which is the order CloudFront matches in, and a route that
13
- # a broader, different one before it would shadow is refused.
13
+ # a broader, different one before it would shadow is refused. The edge function a route
14
+ # names is part of its behaviour, so a route is folded only into one that names the same.
14
15
  class Behaviours
15
16
  # The default behaviour's path: the row that holds the settings of everything else.
16
17
  DEFAULT_PATH = "/*"
@@ -58,11 +59,14 @@ module Hecks
58
59
  policy = @edge.policy(row.cache, row.origin)
59
60
  {
60
61
  path: pattern, origin: @edge.upstream(row.origin).id, methods: methods([row]),
61
- viewer_protocol: protocol(row), compress: row.compress,
62
- cache_policy: Checks.resolve(policy.cache),
63
- origin_request_policy: reference(policy.origin_request),
64
- response_headers_policy: reference(policy.response_headers)
65
- }
62
+ viewer_protocol: protocol(row), compress: row.compress
63
+ }.merge(policies(policy)).merge(row.edge ? { edge_function: row.edge } : {})
64
+ end
65
+
66
+ def policies(policy)
67
+ { cache_policy: Checks.resolve(policy.cache),
68
+ origin_request_policy: reference(policy.origin_request),
69
+ response_headers_policy: reference(policy.response_headers) }
66
70
  end
67
71
 
68
72
  def reference(text) = text.nil? || text == "none" ? nil : Checks.resolve(text)
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "findings"
4
4
  require_relative "route_checks"
5
+ require_relative "function_checks"
5
6
 
6
7
  module Hecks
7
8
  module Projections
@@ -47,6 +48,7 @@ module Hecks
47
48
  check_policies
48
49
  check_upstreams
49
50
  RouteChecks.new(@edge, @rows, @vocabulary, @problems).call
51
+ FunctionChecks.new(@edge, @rows, @vocabulary, @problems).call
50
52
  end
51
53
 
52
54
  private
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "findings"
4
+ require_relative "function_code"
5
+
6
+ module Hecks
7
+ module Projections
8
+ module Site
9
+ class Edge
10
+ # The part of the edge checks that reads the `EdgeFunction` rows and the routes that name
11
+ # them: each row is well formed and safe to write into a function, each route names a row
12
+ # that exists, and no row is left that nothing names.
13
+ class FunctionChecks
14
+ include Findings
15
+
16
+ # The longest logical id a function takes; the name it is given in the account adds the
17
+ # stack name, and CloudFront allows 64 characters in all.
18
+ NAME_LIMIT = 40
19
+
20
+ # @param edge [Edge] the edge being read, its rows set
21
+ # @param rows [Array<Table::Row>] the route table's rows
22
+ # @param vocabulary [Hash{Symbol => Array<String>}] the Site chapter's closed sets
23
+ # @param problems [Array<String>] collects each problem found, one line each
24
+ def initialize(edge, rows, vocabulary, problems)
25
+ @edge = edge
26
+ @rows = rows
27
+ @vocabulary = vocabulary
28
+ @problems = problems
29
+ end
30
+
31
+ # @return [void]
32
+ def call
33
+ @edge.functions.each { |function| check_function(function) }
34
+ repeated(@edge.functions.map(&:name), "EdgeFunction", &:itself)
35
+ check_derived_names
36
+ check_routes
37
+ check_unused
38
+ end
39
+
40
+ private
41
+
42
+ def check_function(function)
43
+ label = "EdgeFunction #{function.name}"
44
+ check_name(label, function)
45
+ member_of(label, "kind", function.kind, :edge_function_kind)
46
+ check_host(label, "host", function.host)
47
+ function.alias_list.each { |host| check_alias(label, function, host) }
48
+ check_paths(label, function)
49
+ end
50
+
51
+ # Two logical ids that differ only in case would be given one function name.
52
+ def check_derived_names
53
+ names = @edge.functions.map { |function| function.name.to_s }.uniq
54
+ names.group_by { |name| FunctionCode.kebab(name) }.each do |kebab, together|
55
+ problem("EdgeFunction", "#{together.join(" and ")} would both be named #{kebab}") if together.size > 1
56
+ end
57
+ end
58
+
59
+ def check_name(label, function)
60
+ name = function.name.to_s
61
+ problem(label, "is not a logical id") unless Checks::LOGICAL_ID.match?(name)
62
+ problem(label, "is longer than #{NAME_LIMIT} characters") if name.size > NAME_LIMIT
63
+ end
64
+
65
+ def check_host(label, field, host)
66
+ return if FunctionCode.host?(host)
67
+
68
+ problem(label, "has #{field} #{host.inspect}; #{field == "alias" ? "an" : "a"} #{field} is a lowercase hostname " \
69
+ "such as app.example.test")
70
+ end
71
+
72
+ def check_alias(label, function, host)
73
+ check_host(label, "alias", host)
74
+ problem(label, "has alias #{host}, which is its host; an alias is another host") if host == function.host
75
+ end
76
+
77
+ def check_paths(label, function)
78
+ problem(label, "needs at least one path to watch") if function.path_list.empty?
79
+ function.path_list.each do |path|
80
+ next if FunctionCode.path?(path)
81
+
82
+ problem(label, "has path #{path.inspect}; a path is exact, starts with / and holds letters, digits and . _ ~ - /")
83
+ end
84
+ end
85
+
86
+ # Every `edge:` names a row, and a route the CDN never sees has no edge to run it.
87
+ def check_routes
88
+ @rows.select(&:edge).each do |row|
89
+ problem(row.path, "names edge #{row.edge}, which no EdgeFunction declares") unless @edge.function(row.edge)
90
+ problem(row.path, "names edge #{row.edge} and has cdn false; the edge never sees it") unless row.cdn
91
+ end
92
+ end
93
+
94
+ def check_unused
95
+ named = @rows.select(&:edge).map(&:edge)
96
+ @edge.functions.reject { |function| named.include?(function.name) }.each do |function|
97
+ problem("EdgeFunction #{function.name}", "is named by no route; remove the row or add edge: to a route")
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Hecks
6
+ module Projections
7
+ module Site
8
+ class Edge
9
+ # The CloudFront function resource of a `redirect_host` row, and the JavaScript in it.
10
+ #
11
+ # The code is a fixed template (`redirect_host.js.tmpl`) with three values filled in: the
12
+ # canonical host, the alias hosts and the watched paths. A value is never escaped. One
13
+ # outside the strict shape of a lowercase hostname, or of a path made of unreserved
14
+ # characters, is refused, so no value can end a JavaScript string, a YAML block or a
15
+ # CloudFormation expression. The checks refuse such a row when it is read, and `code`
16
+ # refuses again, since it is the last step before the text is written.
17
+ module FunctionCode
18
+ # A lowercase hostname of at least two labels, as the host header is compared after
19
+ # lowercasing.
20
+ HOST = /\A([a-z0-9]([a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}\z/
21
+
22
+ # A path of unreserved characters and slashes; no query, no percent escape, no quote.
23
+ PATH = %r{\A/[A-Za-z0-9._~/-]*\z}
24
+
25
+ # The longest hostname and path a function takes.
26
+ HOST_LIMIT = 253
27
+ PATH_LIMIT = 200
28
+
29
+ TEMPLATE = File.join(__dir__, "redirect_host.js.tmpl")
30
+ PLACEHOLDER = /__(CANONICAL|ALIASES|WATCHED)__/
31
+
32
+ module_function
33
+
34
+ # @param text [String] a candidate hostname
35
+ # @return [Boolean] whether it may be written into a function
36
+ def host?(text) = text.is_a?(String) && text.size <= HOST_LIMIT && HOST.match?(text)
37
+
38
+ # @param text [String] a candidate path
39
+ # @return [Boolean] whether it may be written into a function
40
+ def path?(text) = text.is_a?(String) && text.size <= PATH_LIMIT && PATH.match?(text)
41
+
42
+ # @param function [Edge::Function] a `redirect_host` row
43
+ # @return [String] the function's JavaScript, ending in a newline
44
+ # @raise [ArgumentError] when a host or path is outside the strict shapes
45
+ def code(function)
46
+ ensure_safe(function)
47
+ values = { "CANONICAL" => function.host, "ALIASES" => function.alias_list, "WATCHED" => function.path_list }
48
+ File.read(TEMPLATE).gsub(PLACEHOLDER) { JSON.generate(values.fetch(Regexp.last_match(1))) }
49
+ end
50
+
51
+ # @param name [String] a function's logical id
52
+ # @return [String] the hyphenated name the function carries, such as `host-redirect`
53
+ def kebab(name) = name.gsub(/([a-z0-9])([A-Z])/, '\1-\2').downcase
54
+
55
+ # @param function [Edge::Function] a `redirect_host` row
56
+ # @return [String] the resource, from the left margin, ending in a newline
57
+ def resource(function)
58
+ [
59
+ "#{function.name}:", " Type: AWS::CloudFront::Function", " Properties:",
60
+ " Name: !Sub \"${AWS::StackName}-#{kebab(function.name)}\"", " AutoPublish: true",
61
+ " FunctionConfig:", " Comment: Redirect the watched paths from an alias to the canonical host",
62
+ " Runtime: cloudfront-js-2.0", " FunctionCode: |",
63
+ *code(function).each_line.map { |line| line.strip.empty? ? "" : " #{line.chomp}" }
64
+ ].join("\n") << "\n"
65
+ end
66
+
67
+ def ensure_safe(function)
68
+ raise ArgumentError, "EdgeFunction #{function.name} has no path to watch" if function.path_list.empty?
69
+
70
+ bad = unsafe_values(function)
71
+ return if bad.empty?
72
+
73
+ raise ArgumentError, "EdgeFunction #{function.name} has values outside the hostname and path shapes: " \
74
+ "#{bad.map(&:inspect).join(", ")}"
75
+ end
76
+
77
+ def unsafe_values(function)
78
+ [function.host, *function.alias_list].reject { |host| host?(host) } +
79
+ function.path_list.reject { |path| path?(path) }
80
+ end
81
+ private_class_method :ensure_safe, :unsafe_values
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,65 @@
1
+ function handler(event) {
2
+ var request = event.request;
3
+ var canonical = __CANONICAL__;
4
+ var aliases = __ALIASES__;
5
+ var watched = __WATCHED__;
6
+ var header = request.headers.host;
7
+ var host = header && header.value ? header.value.toLowerCase().replace(/:\d+$/, "") : "";
8
+ var reads = request.method === "GET" || request.method === "HEAD";
9
+ if (!reads || watched.indexOf(request.uri) < 0) { return request; }
10
+ if (host === canonical || aliases.indexOf(host) < 0) { return request; }
11
+ var parts = [];
12
+ Object.keys(request.querystring).forEach(function (key) {
13
+ var entry = request.querystring[key];
14
+ var values = entry.multiValue ? entry.multiValue.map(function (item) { return item.value; }) : [entry.value];
15
+ values.forEach(function (value) {
16
+ var name = encode(key);
17
+ parts.push(value === "" || value === undefined ? name : name + "=" + encode(value));
18
+ });
19
+ });
20
+ var query = parts.length ? "?" + parts.join("&") : "";
21
+ return {
22
+ statusCode: 302,
23
+ statusDescription: "Found",
24
+ headers: {
25
+ location: { value: "https://" + canonical + request.uri + query },
26
+ "cache-control": { value: "no-store" }
27
+ }
28
+ };
29
+ }
30
+
31
+ // Percent-encodes what cannot stand in a query string and keeps what is already encoded, so the
32
+ // location header carries no space, control character, "&" or "#" whether the runtime hands over
33
+ // the raw text or the decoded one. It walks the text one UTF-16 unit at a time and pairs the
34
+ // surrogates itself (a lone one becomes U+FFFD), because CloudFront's regex engine refuses a
35
+ // pattern that names a surrogate code point and fails the whole function at compile time.
36
+ var SAFE = /[A-Za-z0-9\-._~!$'()*,;:@\/?+]/;
37
+ var HEX = /^[0-9A-Fa-f]{2}$/;
38
+ var REPLACEMENT = "%EF%BF%BD";
39
+
40
+ function encode(text) {
41
+ var input = String(text);
42
+ var output = "";
43
+ var index = 0;
44
+ while (index < input.length) {
45
+ var unit = input.charAt(index);
46
+ var code = input.charCodeAt(index);
47
+ index += 1;
48
+ if (code >= 55296 && code <= 56319) {
49
+ var next = index < input.length ? input.charCodeAt(index) : 0;
50
+ if (next >= 56320 && next <= 57343) {
51
+ output += encodeURIComponent(unit + input.charAt(index));
52
+ index += 1;
53
+ } else {
54
+ output += REPLACEMENT;
55
+ }
56
+ } else if (code >= 56320 && code <= 57343) {
57
+ output += REPLACEMENT;
58
+ } else if (unit === "%") {
59
+ output += HEX.test(input.substr(index, 2)) ? unit : "%25";
60
+ } else {
61
+ output += SAFE.test(unit) ? unit : encodeURIComponent(unit);
62
+ }
63
+ }
64
+ return output;
65
+ }