hecks 3.12.0 → 3.13.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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/adapters/driven/script_runner.adapter +3 -0
  3. data/lib/hecks/adapters/driven/script_runner.rb +93 -0
  4. data/lib/hecks/adapters/driven.rb +1 -0
  5. data/lib/hecks/bluebook/meta_validator/syntax_boot.rb +16 -12
  6. data/lib/hecks/bluebook/meta_validator.rb +28 -21
  7. data/lib/hecks/deploy/bluebook/deploy.bluebook +1 -1
  8. data/lib/hecks/framework/bluebook/change.behaviors +30 -0
  9. data/lib/hecks/framework/bluebook/change.bluebook +162 -0
  10. data/lib/hecks/framework/bluebook/change.hecksagon +21 -0
  11. data/lib/hecks/framework/change.oidc.json +34 -0
  12. data/lib/hecks/framework/console_settings.oidc.json +103 -0
  13. data/lib/hecks/framework/governance.oidc.json +28 -0
  14. data/lib/hecks/framework/identity.oidc.json +18 -0
  15. data/lib/hecks/framework/privacy.oidc.json +30 -0
  16. data/lib/hecks/gate/stages.yml +3 -0
  17. data/lib/hecks/grammar/translation.oidc.json +48 -0
  18. data/lib/hecks/hecks/adapters/codebase/test_runner.rb +12 -1
  19. data/lib/hecks/hecks/adapters/deploy_toolchain.rb +2 -1
  20. data/lib/hecks/projections/deploy/fargate/cdn/lines.rb +12 -1
  21. data/lib/hecks/projections/site/admin_cms/templates/membership.rb +10 -7
  22. data/lib/hecks/projections/site/edge/behaviours.rb +10 -6
  23. data/lib/hecks/projections/site/edge/checks.rb +2 -0
  24. data/lib/hecks/projections/site/edge/function_checks.rb +104 -0
  25. data/lib/hecks/projections/site/edge/function_code.rb +86 -0
  26. data/lib/hecks/projections/site/edge/redirect_host.js.tmpl +65 -0
  27. data/lib/hecks/projections/site/edge.rb +35 -9
  28. data/lib/hecks/projections/site/site_cdn.rb +21 -7
  29. data/lib/hecks/projections/site/table/row_builder.rb +12 -2
  30. data/lib/hecks/projections/site/table.rb +4 -2
  31. data/lib/hecks/site/bluebook/site.bluebook +16 -1
  32. data/lib/hecks/tools/oidc_manifests.rb +44 -6
  33. data/lib/hecks/tools/site_routes/templates.rb +13 -9
  34. data/lib/hecks/version.rb +1 -1
  35. data/prebuilt/{verdicts-df19f35868d3284a70f55e42dcef87ef4e65e6a678e5135f72e1f6cff4c1357b.json → verdicts-48318ca172edd21443b1ad02801c697fff100b746e828f09db987647641a5df2.json} +1 -1
  36. data/rust/host/HECKS_RELEASE +1 -1
  37. data/rust/host/src/auth.rs +58 -0
  38. data/rust/host/src/platform_signin/session.rs +1 -1
  39. metadata +21 -7
  40. /data/lib/hecks/framework/{oidc.json → compliance.oidc.json} +0 -0
  41. /data/lib/hecks/grammar/{oidc.json → expression.oidc.json} +0 -0
  42. /data/prebuilt/{d74a00e996222c1d8b676d5959092effc46b4dc59e0555f3bf9554ac58e3094a.marshal → 2b59737fbdbd5f44edda0fddce2611f45d76087b3a7c782d1f1a17ea58c77948.marshal} +0 -0
  43. /data/prebuilt/{9ee54e2f0d4d6d3fe046827779b221e02de665fba911a048ae5dd8b8fbfb7d90.marshal → 58208399bada10b0db99928c48e38789288fc58f7b71a72b21bfa386680e32ed.marshal} +0 -0
@@ -0,0 +1,48 @@
1
+ {
2
+ "audience": "Translation",
3
+ "scopes": [
4
+ {
5
+ "scope": "translation:map.declare",
6
+ "verb": "Translation::Map.Declare",
7
+ "role": "Author"
8
+ },
9
+ {
10
+ "scope": "translation:map.draft",
11
+ "verb": "Translation::Map.Draft",
12
+ "role": "Author"
13
+ },
14
+ {
15
+ "scope": "translation:map.seal",
16
+ "verb": "Translation::Map.Seal",
17
+ "role": "Author"
18
+ },
19
+ {
20
+ "scope": "translation:map.supersede",
21
+ "verb": "Translation::Map.Supersede",
22
+ "role": "Author"
23
+ },
24
+ {
25
+ "scope": "translation:rule.admit",
26
+ "verb": "Translation::Rule.Admit",
27
+ "role": "Author"
28
+ },
29
+ {
30
+ "scope": "translation:rule.execute",
31
+ "verb": "Translation::Rule.Execute",
32
+ "role": "Author"
33
+ },
34
+ {
35
+ "scope": "translation:rule.propose",
36
+ "verb": "Translation::Rule.Propose",
37
+ "role": "Author"
38
+ },
39
+ {
40
+ "scope": "translation:rule.retire",
41
+ "verb": "Translation::Rule.Retire",
42
+ "role": "Author"
43
+ }
44
+ ],
45
+ "roles": [
46
+ "Author"
47
+ ]
48
+ }
@@ -45,12 +45,23 @@ module Hecks
45
45
  # What the runner printed for the example, when it passed.
46
46
  def run_example(path, example)
47
47
  out = StringIO.new
48
- status = Dir.chdir(@tree.root) { runner.call(["--example", example, path], out, out) }
48
+ status = Dir.chdir(@tree.root) { at_reaction_depth_zero { runner.call(["--example", example, path], out, out) } }
49
49
  raise ConsoleCapture::Failure, out.string.strip unless status.zero?
50
50
 
51
51
  out.string.strip
52
52
  end
53
53
 
54
+ # The runner is asked for from inside a command's own reaction cascade, and the dispatcher
55
+ # keeps that cascade's depth on the thread. An example starts at depth zero, as a plain run
56
+ # does, so a spec that cascades four deep is not cut off by the cascade that launched it.
57
+ def at_reaction_depth_zero
58
+ depth = Thread.current[:hecks_reaction_depth]
59
+ Thread.current[:hecks_reaction_depth] = 0
60
+ yield
61
+ ensure
62
+ Thread.current[:hecks_reaction_depth] = depth
63
+ end
64
+
54
65
  def runner
55
66
  return self.class.runner if self.class.runner
56
67
 
@@ -74,7 +74,8 @@ module Hecks
74
74
  { report: { value: child(:lint, paths).dig(:output, :value) } }
75
75
  end
76
76
 
77
- # Projects each domain's OIDC client and scope manifest into its `oidc.json`, through
77
+ # Projects each domain's OIDC client and scope manifest into its `oidc.json`, or into one
78
+ # `<chapter>.oidc.json` for each chapter of a directory that holds several, through
78
79
  # `hecks deploy oidc_manifest.project_oidc`.
79
80
  #
80
81
  # @param held [Hash] the `OidcManifest` record: `domains` (comma separated directories; every
@@ -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
+ }
@@ -7,6 +7,7 @@ require_relative "edge/row_reader"
7
7
  require_relative "edge/checks"
8
8
  require_relative "edge/behaviours"
9
9
  require_relative "edge/rules"
10
+ require_relative "edge/function_code"
10
11
 
11
12
  module Hecks
12
13
  module Projections
@@ -16,17 +17,33 @@ module Hecks
16
17
  #
17
18
  # The routes are the table's own `Route` rows. What a table cannot know, the names a template
18
19
  # gives its policies, origins, target groups and listener, a project declares as `member`
19
- # rows of four more value objects of the same chapter: `Edge` (the template and listener),
20
- # `EdgePolicy`, `EdgeOrigin` and `EdgeRule`. `docs/site-routes.md` lists their fields. A
21
- # project that declares none of the four has no edge to project.
20
+ # rows of five more value objects of the same chapter: `Edge` (the template and listener),
21
+ # `EdgePolicy`, `EdgeOrigin`, `EdgeRule` and `EdgeFunction` (a viewer-request function a route
22
+ # names with `edge:`). `docs/site-routes.md` lists their fields. A project that declares none
23
+ # of the five has no edge to project.
22
24
  class Edge
23
25
  Setting = Struct.new(:template, :listener, :secret_header, :secret_value, :alb, keyword_init: true)
24
26
  Policy = Struct.new(:cache_class, :origin, :cache, :origin_request, :response_headers, keyword_init: true)
25
27
  Upstream = Struct.new(:origin, :id, :target_group, keyword_init: true)
26
28
  Rule = Struct.new(:rule, :priority, :origin, keyword_init: true)
27
29
 
30
+ # A function the generator writes: `kind` says which, and the rest are its settings, the
31
+ # lists as comma-separated text.
32
+ Function = Struct.new(:name, :kind, :host, :aliases, :paths, keyword_init: true) do
33
+ # @return [Array<String>] the alias hosts that are redirected
34
+ def alias_list = split(aliases)
35
+
36
+ # @return [Array<String>] the exact paths the function watches
37
+ def path_list = split(paths)
38
+
39
+ private
40
+
41
+ def split(text) = text.to_s.split(",").map(&:strip).reject(&:empty?)
42
+ end
43
+
28
44
  # The value objects a project declares its edge rows in, by the kind of record each holds.
29
- OBJECTS = { setting: "Edge", policy: "EdgePolicy", upstream: "EdgeOrigin", rule: "EdgeRule" }.freeze
45
+ OBJECTS = { setting: "Edge", policy: "EdgePolicy", upstream: "EdgeOrigin", rule: "EdgeRule",
46
+ function: "EdgeFunction" }.freeze
30
47
 
31
48
  # The fields each kind of row may carry, with the Ruby class each takes.
32
49
  FIELDS = {
@@ -34,14 +51,15 @@ module Hecks
34
51
  policy: { cache_class: String, origin: String, cache: String, origin_request: String,
35
52
  response_headers: String },
36
53
  upstream: { origin: String, id: String, target_group: String },
37
- rule: { rule: String, priority: Integer, origin: String }
54
+ rule: { rule: String, priority: Integer, origin: String },
55
+ function: { name: String, kind: String, host: String, aliases: String, paths: String }
38
56
  }.freeze
39
57
 
40
58
  # The fields a row of each kind must carry. The template may instead be named to the tool.
41
59
  REQUIRED = { setting: [], policy: %i[cache_class cache], upstream: %i[origin id],
42
- rule: %i[rule priority origin] }.freeze
60
+ rule: %i[rule priority origin], function: %i[name kind host aliases paths] }.freeze
43
61
 
44
- STRUCTS = { setting: Setting, policy: Policy, upstream: Upstream, rule: Rule }.freeze
62
+ STRUCTS = { setting: Setting, policy: Policy, upstream: Upstream, rule: Rule, function: Function }.freeze
45
63
 
46
64
  # @return [Setting] the template and the listener
47
65
  attr_reader :setting
@@ -51,6 +69,8 @@ module Hecks
51
69
  attr_reader :upstreams
52
70
  # @return [Array<Rule>] the listener rules
53
71
  attr_reader :rules
72
+ # @return [Array<Function>] the edge functions the generator writes
73
+ attr_reader :functions
54
74
  # @return [Hash{Symbol => Object}] the default behaviour as `Cdn.behavior_lines` takes it
55
75
  attr_reader :default_behaviour
56
76
  # @return [Array<Hash{Symbol => Object}>] the other behaviours, in matching order
@@ -65,11 +85,12 @@ module Hecks
65
85
  # @param vocabulary [Hash{Symbol => Array<String>}] the Site chapter's closed sets
66
86
  # @param template [String, nil] the template the caller names, which stands in for the
67
87
  # `Edge` row's own `template:`
68
- # @return [Edge, nil] the checked edge, or nil when the chapter declares none
88
+ # @return [Edge, nil] the checked edge, or nil when the chapter declares none and no route
89
+ # names an edge function
69
90
  # @raise [Table::Invalid] when a row is refused or the edge contradicts the routes
70
91
  def self.read(chapter, table:, vocabulary:, template: nil)
71
92
  declared = OBJECTS.transform_values { |name| Table.rows_of(chapter, name) }
72
- return nil if declared.values.all?(&:empty?)
93
+ return nil if declared.values.all?(&:empty?) && table.rows.none?(&:edge)
73
94
 
74
95
  problems = []
75
96
  records = declared.to_h { |kind, members| [kind, build(kind, members, problems)] }
@@ -104,6 +125,7 @@ module Hecks
104
125
  @policies = records.fetch(:policy)
105
126
  @upstreams = records.fetch(:upstream)
106
127
  @rules = records.fetch(:rule)
128
+ @functions = records.fetch(:function)
107
129
  end
108
130
  private :assign
109
131
 
@@ -119,6 +141,10 @@ module Hecks
119
141
  # unless the `Edge` row says `alb: false`
120
142
  def alb? = setting.nil? || setting.alb != false
121
143
 
144
+ # @param name [String] a logical id
145
+ # @return [Function, nil] the function row of that name
146
+ def function(name) = functions.find { |candidate| candidate.name == name }
147
+
122
148
  # @param origin [String] an origin of the Origin set
123
149
  # @return [Upstream, nil] its CloudFront origin and target group
124
150
  def upstream(origin) = upstreams.find { |candidate| candidate.origin == origin }
@@ -7,12 +7,14 @@ require_relative "edge"
7
7
  module Hecks
8
8
  module Projections
9
9
  module Site
10
- # The edge of a site's route table as the two blocks of a CloudFormation template that depend
11
- # on it: the distribution's cache behaviours and the load balancer's listener rules.
10
+ # The edge of a site's route table as the blocks of a CloudFormation template that depend on
11
+ # it: the distribution's cache behaviours, the load balancer's listener rules and the
12
+ # functions the behaviours attach.
12
13
  #
13
- # The blocks are returned by name, `"behaviors"` and `"listener_rules"`, as text from the
14
- # left margin; a project whose `Edge` row says `alb: false` has no load balancer and gets
15
- # `"behaviors"` alone. `Regions` puts each between the markers of the same name in the
14
+ # The blocks are returned by name, `"behaviors"`, `"listener_rules"` and `"edge_functions"`,
15
+ # as text from the left margin; a project whose `Edge` row says `alb: false` has no load
16
+ # balancer and gets no `"listener_rules"`, and one with no `EdgeFunction` row gets no
17
+ # `"edge_functions"`. `Regions` puts each between the markers of the same name in the
16
18
  # project's template, at the markers' indentation. The text is a pure function of the table
17
19
  # and the edge rows.
18
20
  module SiteCdn
@@ -34,9 +36,21 @@ module Hecks
34
36
  edge = options[:edge] || Edge.read(bluebook, table: table, vocabulary: Table.vocabulary(registry))
35
37
  return {} unless edge
36
38
 
37
- return { "behaviors" => behaviors(edge) } unless edge.alb?
39
+ regions(edge)
40
+ end
38
41
 
39
- { "behaviors" => behaviors(edge), "listener_rules" => listener_rules(edge) }
42
+ # @param edge [Edge] a checked edge
43
+ # @return [Hash{String => String}] the regions the edge needs
44
+ def regions(edge)
45
+ { "behaviors" => behaviors(edge) }
46
+ .merge(edge.alb? ? { "listener_rules" => listener_rules(edge) } : {})
47
+ .merge(edge.functions.any? ? { "edge_functions" => edge_functions(edge) } : {})
48
+ end
49
+
50
+ # @param edge [Edge] a checked edge
51
+ # @return [String] one function resource per `EdgeFunction` row, ending in a newline
52
+ def edge_functions(edge)
53
+ edge.functions.map { |function| Edge::FunctionCode.resource(function) }.join("\n")
40
54
  end
41
55
 
42
56
  # @param edge [Edge] a checked edge
@@ -11,7 +11,7 @@ module Hecks
11
11
  :indexable, :switch, :off, :preview, :label, :seo, :redirect_to, :aliases,
12
12
  :nav_group, :nav_order, :mobile_order, :footer_column, :footer_order,
13
13
  :admin_key, :admin_order, :compress, :alb_rule, :cdn,
14
- :seo_title, :edge_verbs, :mobile_heading, :explicit_cache, keyword_init: true)
14
+ :seo_title, :edge_verbs, :mobile_heading, :explicit_cache, :edge, keyword_init: true)
15
15
 
16
16
  # Turns a declared member into a `Row`: checks its fields and their types, fills the
17
17
  # defaults, and checks each closed-set value against the Site chapter's vocabulary.
@@ -23,7 +23,10 @@ module Hecks
23
23
  aliases: String, nav_group: String, nav_order: Integer, mobile_order: Integer,
24
24
  footer_column: String, footer_order: Integer, admin_key: String,
25
25
  admin_order: Integer, compress: :bool, alb_rule: String, cdn: :bool,
26
- seo_title: String, edge_methods: String, mobile_heading: String }.freeze
26
+ seo_title: String, edge_methods: String, mobile_heading: String, edge: String }.freeze
27
+
28
+ # What an `edge:` names: the logical id of an `EdgeFunction` row.
29
+ EDGE_NAME = /\A[A-Za-z][A-Za-z0-9]*\z/
27
30
 
28
31
  # The value objects of the Site chapter's `Route` aggregate whose members are the closed
29
32
  # sets, by the row field each constrains.
@@ -103,6 +106,13 @@ module Hecks
103
106
  problem(label, "has #{field} #{row[field].inspect}; #{field} is one of #{@vocabulary.fetch(field).join(", ")}")
104
107
  end
105
108
  check_verbs(row, label)
109
+ check_edge(row, label)
110
+ end
111
+
112
+ def check_edge(row, label)
113
+ return unless row.edge.is_a?(String) && !EDGE_NAME.match?(row.edge)
114
+
115
+ problem(label, "has edge #{row.edge.inspect}; an edge is the logical id of an EdgeFunction row")
106
116
  end
107
117
 
108
118
  def check_verbs(row, label)