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.
- checksums.yaml +4 -4
- data/lib/hecks/adapters/driven/script_runner.adapter +3 -0
- data/lib/hecks/adapters/driven/script_runner.rb +93 -0
- data/lib/hecks/adapters/driven.rb +1 -0
- data/lib/hecks/bluebook/meta_validator/syntax_boot.rb +16 -12
- data/lib/hecks/bluebook/meta_validator.rb +28 -21
- data/lib/hecks/deploy/bluebook/deploy.bluebook +1 -1
- data/lib/hecks/framework/bluebook/change.behaviors +30 -0
- data/lib/hecks/framework/bluebook/change.bluebook +162 -0
- data/lib/hecks/framework/bluebook/change.hecksagon +21 -0
- data/lib/hecks/framework/change.oidc.json +34 -0
- data/lib/hecks/framework/console_settings.oidc.json +103 -0
- data/lib/hecks/framework/governance.oidc.json +28 -0
- data/lib/hecks/framework/identity.oidc.json +18 -0
- data/lib/hecks/framework/privacy.oidc.json +30 -0
- data/lib/hecks/gate/stages.yml +3 -0
- data/lib/hecks/grammar/translation.oidc.json +48 -0
- data/lib/hecks/hecks/adapters/codebase/test_runner.rb +12 -1
- data/lib/hecks/hecks/adapters/deploy_toolchain.rb +2 -1
- data/lib/hecks/projections/deploy/fargate/cdn/lines.rb +12 -1
- data/lib/hecks/projections/site/admin_cms/templates/membership.rb +10 -7
- data/lib/hecks/projections/site/edge/behaviours.rb +10 -6
- data/lib/hecks/projections/site/edge/checks.rb +2 -0
- data/lib/hecks/projections/site/edge/function_checks.rb +104 -0
- data/lib/hecks/projections/site/edge/function_code.rb +86 -0
- data/lib/hecks/projections/site/edge/redirect_host.js.tmpl +65 -0
- data/lib/hecks/projections/site/edge.rb +35 -9
- data/lib/hecks/projections/site/site_cdn.rb +21 -7
- data/lib/hecks/projections/site/table/row_builder.rb +12 -2
- data/lib/hecks/projections/site/table.rb +4 -2
- data/lib/hecks/site/bluebook/site.bluebook +16 -1
- data/lib/hecks/tools/oidc_manifests.rb +44 -6
- data/lib/hecks/tools/site_routes/templates.rb +13 -9
- data/lib/hecks/version.rb +1 -1
- data/prebuilt/{verdicts-df19f35868d3284a70f55e42dcef87ef4e65e6a678e5135f72e1f6cff4c1357b.json → verdicts-48318ca172edd21443b1ad02801c697fff100b746e828f09db987647641a5df2.json} +1 -1
- data/rust/host/HECKS_RELEASE +1 -1
- data/rust/host/src/auth.rs +58 -0
- data/rust/host/src/platform_signin/session.rs +1 -1
- metadata +21 -7
- /data/lib/hecks/framework/{oidc.json → compliance.oidc.json} +0 -0
- /data/lib/hecks/grammar/{oidc.json → expression.oidc.json} +0 -0
- /data/prebuilt/{d74a00e996222c1d8b676d5959092effc46b4dc59e0555f3bf9554ac58e3094a.marshal → 2b59737fbdbd5f44edda0fddce2611f45d76087b3a7c782d1f1a17ea58c77948.marshal} +0 -0
- /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`,
|
|
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
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
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
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
|
20
|
-
# `EdgePolicy`, `EdgeOrigin` and `
|
|
21
|
-
#
|
|
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"
|
|
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
|
|
11
|
-
#
|
|
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 `"
|
|
14
|
-
# left margin; a project whose `Edge` row says `alb: false` has no load
|
|
15
|
-
#
|
|
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
|
-
|
|
39
|
+
regions(edge)
|
|
40
|
+
end
|
|
38
41
|
|
|
39
|
-
|
|
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)
|