hecks 3.9.1 → 3.10.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/deploy/bluebook/deploy.bluebook +106 -0
- data/lib/hecks/deploy/bluebook/deploy.hecksagon +8 -0
- data/lib/hecks/deploy/oidc.json +16 -3
- data/lib/hecks/hecks/adapters/deploy_toolchain/clearing.rb +51 -0
- data/lib/hecks/hecks/adapters/deploy_toolchain.rb +2 -0
- data/lib/hecks/hecks/adapters/site_toolchain/role_prober.rb +78 -0
- data/lib/hecks/hecks/adapters/site_toolchain.rb +29 -4
- data/lib/hecks/hecks/adapters/world_clearing.rb +65 -0
- data/lib/hecks/hecks/hecks.hecksagon +8 -0
- data/lib/hecks/hecks/hecks.world +2 -1
- data/lib/hecks/projections/deploy/fargate/settings/tuning.rb +15 -0
- data/lib/hecks/projections/deploy/fargate/settings.rb +3 -1
- data/lib/hecks/projections/deploy/fargate/stack/readers.rb +1 -0
- data/lib/hecks/projections/deploy/templates/fargate/Makefile.tmpl +7 -4
- data/lib/hecks/projections/site/cms_editor/attributes.rb +75 -0
- data/lib/hecks/projections/site/cms_editor/checks.rb +64 -0
- data/lib/hecks/projections/site/cms_editor/media.rb +85 -0
- data/lib/hecks/projections/site/cms_editor/pretty.rb +40 -0
- data/lib/hecks/projections/site/cms_editor/schema.rb +84 -0
- data/lib/hecks/projections/site/cms_editor/setting.rb +87 -0
- data/lib/hecks/projections/site/cms_editor/templates/package.json.tmpl +19 -0
- data/lib/hecks/projections/site/cms_editor/templates/src/app.ts.tmpl +176 -0
- data/lib/hecks/projections/site/cms_editor/templates/src/auth/membership.ts.tmpl +79 -0
- data/lib/hecks/projections/site/cms_editor/templates/src/auth/session.ts.tmpl +36 -0
- data/lib/hecks/projections/site/cms_editor/templates/src/auth/sso.ts.tmpl +20 -0
- data/lib/hecks/projections/site/cms_editor/templates/src/commands.ts.tmpl +79 -0
- data/lib/hecks/projections/site/cms_editor/templates/src/config.ts.tmpl +23 -0
- data/lib/hecks/projections/site/cms_editor/templates/src/host.ts.tmpl +26 -0
- data/lib/hecks/projections/site/cms_editor/templates/src/media/handler.ts.tmpl +154 -0
- data/lib/hecks/projections/site/cms_editor/templates/src/media/multipart.ts.tmpl +53 -0
- data/lib/hecks/projections/site/cms_editor/templates/src/media/sniff.ts.tmpl +94 -0
- data/lib/hecks/projections/site/cms_editor/templates/src/media/storage.ts.tmpl +50 -0
- data/lib/hecks/projections/site/cms_editor/templates/src/schema.ts.tmpl +85 -0
- data/lib/hecks/projections/site/cms_editor/templates/src/server.ts.tmpl +65 -0
- data/lib/hecks/projections/site/cms_editor/templates/src/ui/body_model.js.tmpl +120 -0
- data/lib/hecks/projections/site/cms_editor/templates/src/ui/body_parse.js.tmpl +320 -0
- data/lib/hecks/projections/site/cms_editor/templates/src/ui/body_widget.js.tmpl +282 -0
- data/lib/hecks/projections/site/cms_editor/templates/src/ui/fields.ts.tmpl +80 -0
- data/lib/hecks/projections/site/cms_editor/templates/src/ui/html.ts.tmpl +102 -0
- data/lib/hecks/projections/site/cms_editor/templates/src/ui/input.ts.tmpl +75 -0
- data/lib/hecks/projections/site/cms_editor/templates/src/ui/media_picker.js.tmpl +109 -0
- data/lib/hecks/projections/site/cms_editor/templates/src/ui/outline.ts.tmpl +27 -0
- data/lib/hecks/projections/site/cms_editor/templates/src/ui/pages.ts.tmpl +123 -0
- data/lib/hecks/projections/site/cms_editor/templates/tsconfig.json.tmpl +16 -0
- data/lib/hecks/projections/site/cms_editor.rb +107 -0
- data/lib/hecks/projections/site/role_probe.rb +72 -0
- data/lib/hecks/site/bluebook/site.bluebook +28 -1
- data/lib/hecks/site/bluebook/site.ports.hecksagon +6 -0
- data/lib/hecks/tools/site_routes/cli.rb +1 -1
- data/lib/hecks/tools/site_routes/editor_files.rb +38 -0
- data/lib/hecks/tools/site_routes.rb +14 -7
- data/lib/hecks/version.rb +1 -1
- data/prebuilt/{verdicts-91aaf93ce8981bd9a754948f075d95650dc2ec6435015376f5c60992b36a24b1.json → verdicts-d6d584ad43b5f3cebf88aafbbdbf024c38574acf18e06adffbc555f7786b1c2f.json} +1 -1
- data/rust/Cargo.toml +3 -1
- data/rust/codegen/src/bridging.rs +1 -1
- data/rust/codegen/src/domain_generator.rs +9 -9
- data/rust/codegen/src/main.rs +1 -1
- data/rust/codegen/src/mutations.rs +46 -1
- data/rust/codegen/src/naming.rs +30 -0
- data/rust/host/HECKS_RELEASE +1 -1
- data/rust/src/kernel/expr.rs +7 -1
- data/rust/src/kernel/expression_operators/presence.rs +41 -3
- metadata +41 -5
- /data/prebuilt/{6e4a6bcf0aa880e2e6b056e1846b70e5a768090b9dc998bbadeaf5d6bcfd3db8.marshal → c28d6d9db14eea3e17161e9d9c262efee30b256cf97693a7cfd552bce24b2f7f.marshal} +0 -0
- /data/prebuilt/{0ee7f83cba59ce7d1c45c65ba166b071779f70143449f584f33ff7989f64eccb.marshal → cb375cba61ca0dbe8ddb9739c306510fe05e5f4b7a9b6955caff8224c63ea0e0.marshal} +0 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hecks
|
|
4
|
+
module Projections
|
|
5
|
+
module Site
|
|
6
|
+
module CmsEditor
|
|
7
|
+
# What the `Editor` row must satisfy beyond the class of each field: paths that start with
|
|
8
|
+
# a slash, a sign-in path the editor serves itself, a role to admit, and a login page a
|
|
9
|
+
# visitor with no session can reach.
|
|
10
|
+
module Checks
|
|
11
|
+
# A base path: one or more segments, starting with a slash and not ending with one.
|
|
12
|
+
BASE_PATH = %r{\A(/[^/\s]+)+\z}
|
|
13
|
+
|
|
14
|
+
# The largest upload size cap a row may name: 100 MiB.
|
|
15
|
+
MAX_UPLOAD = 104_857_600
|
|
16
|
+
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
# @param row [Hash{Symbol => String}] the row, defaults filled
|
|
20
|
+
# @param roles [Array<String>] the roles the row names
|
|
21
|
+
# @param table [Table, nil] the checked route table, to check the login page against
|
|
22
|
+
# @return [Array<String>] every problem, in the order the fields are declared
|
|
23
|
+
def problems(row, roles, table)
|
|
24
|
+
base = row[:base_path]
|
|
25
|
+
[*path_problems(row, base), *("Editor roles name no role" if roles.empty?), *login_problems(row[:login], table),
|
|
26
|
+
*media_problems(row)]
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @return [Array<String>] the problems with the base path and the sign-in path
|
|
30
|
+
def path_problems(row, base)
|
|
31
|
+
found = []
|
|
32
|
+
found << "base_path #{base.inspect} must start with a slash and not end with one" unless BASE_PATH.match?(base)
|
|
33
|
+
found << "sso_path #{row[:sso_path]} must be under #{base}/" unless row[:sso_path].start_with?("#{base}/")
|
|
34
|
+
found
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @return [Array<String>] the problems with the picture directory and the upload size cap
|
|
38
|
+
def media_problems(row)
|
|
39
|
+
found = []
|
|
40
|
+
found << "media_dir must not be empty" if row[:media_dir].strip.empty?
|
|
41
|
+
cap = row[:media_max_bytes]
|
|
42
|
+
unless cap.match?(/\A[1-9]\d{0,9}\z/) && cap.to_i <= MAX_UPLOAD
|
|
43
|
+
found << "media_max_bytes #{cap.inspect} must be a whole number of bytes from 1 to #{MAX_UPLOAD}"
|
|
44
|
+
end
|
|
45
|
+
found
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# The login page is a public row: a visitor with no session must reach it.
|
|
49
|
+
#
|
|
50
|
+
# @return [Array<String>] the problems with the login page
|
|
51
|
+
def login_problems(login, table)
|
|
52
|
+
return ["login #{login} must start with a slash"] unless login.start_with?("/")
|
|
53
|
+
return [] if table.nil?
|
|
54
|
+
|
|
55
|
+
found = table.rows.find { |candidate| candidate.path == login }
|
|
56
|
+
return ["login #{login} is not a route of the table"] if found.nil?
|
|
57
|
+
|
|
58
|
+
found.auth == "public" ? [] : ["login #{login} is #{found.auth}; the login page must be public"]
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hecks
|
|
4
|
+
module Projections
|
|
5
|
+
module Site
|
|
6
|
+
module CmsEditor
|
|
7
|
+
# The aggregate that registers pictures, found by shape in the schema the editor reads.
|
|
8
|
+
#
|
|
9
|
+
# It is an aggregate with a creating command whose attributes are the aggregate's identity
|
|
10
|
+
# (the key), an alt text and a mime type, and optionally a width and a height; every other
|
|
11
|
+
# attribute of that command must be optional, because an upload cannot fill it. Neither the
|
|
12
|
+
# aggregate's name nor the command's decides it; the attribute names are the vocabulary, the
|
|
13
|
+
# way `blocks`, `kind` and `spans` are for a document body. An attribute is a plain value or
|
|
14
|
+
# a value object whose first part is a plain value; the editor sends a value the same way.
|
|
15
|
+
#
|
|
16
|
+
# The domain keeps the picture's record, never its bytes.
|
|
17
|
+
module Media
|
|
18
|
+
# The attribute names that carry each part of a picture's record besides its key.
|
|
19
|
+
ROLES = { "alt" => %w[alt alt_text], "mime" => %w[mime_type mime content_type],
|
|
20
|
+
"width" => %w[width], "height" => %w[height] }.freeze
|
|
21
|
+
|
|
22
|
+
# The parts every picture's registration must take.
|
|
23
|
+
REQUIRED = %w[key alt mime].freeze
|
|
24
|
+
|
|
25
|
+
module_function
|
|
26
|
+
|
|
27
|
+
# @param aggregates [Array<Hash{String => Object}>] the aggregates, as `Schema` reads them
|
|
28
|
+
# @return [Hash{String => Object}, nil] the aggregate and command that register a
|
|
29
|
+
# picture, each part's attribute and the query that lists the pictures; nil when no
|
|
30
|
+
# aggregate has the shape
|
|
31
|
+
def read(aggregates)
|
|
32
|
+
aggregates.each do |agg|
|
|
33
|
+
agg["commands"].select { |command| command["creates"] }.each do |command|
|
|
34
|
+
found = fields(agg, command)
|
|
35
|
+
return describe(agg, command, found) if found
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
nil
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# @return [Hash{String => Hash}, nil] each part's attribute and how its value is sent, or
|
|
42
|
+
# nil when the command is not a picture's registration
|
|
43
|
+
def fields(agg, command)
|
|
44
|
+
chosen = parts(agg, command["attributes"])
|
|
45
|
+
return nil unless REQUIRED.all? { |role| chosen.key?(role) } && others_optional?(command, chosen)
|
|
46
|
+
|
|
47
|
+
wrapped = chosen.transform_values { |attr| wrap(agg, attr) }
|
|
48
|
+
return nil if wrapped.value?(false)
|
|
49
|
+
|
|
50
|
+
chosen.to_h { |role, attr| [role, { "name" => attr["name"], "wrap" => wrapped.fetch(role) }] }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# @return [Boolean] whether every attribute that is not a part of the record is optional
|
|
54
|
+
def others_optional?(command, chosen)
|
|
55
|
+
(command["attributes"] - chosen.values).all? { |attr| attr["optional"] }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# @return [Hash{String => Hash}] each part of the record to the attribute that carries it
|
|
59
|
+
def parts(agg, attrs)
|
|
60
|
+
found = { "key" => attrs.find { |attr| attr["name"] == agg["identity"] } }
|
|
61
|
+
ROLES.each { |role, names| found[role] = attrs.find { |attr| names.include?(attr["name"]) } }
|
|
62
|
+
found.compact
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# @return [String, nil, false] the part a value object's value is sent in, nil for a
|
|
66
|
+
# plain value, false for an attribute an upload cannot fill
|
|
67
|
+
def wrap(agg, attr)
|
|
68
|
+
return false if attr["list"] || attr["kind"] == "reference"
|
|
69
|
+
return nil unless attr["kind"] == "object"
|
|
70
|
+
|
|
71
|
+
first = agg["valueObjects"].fetch(attr["type"], []).first
|
|
72
|
+
first && first["kind"] != "object" && !first["list"] ? first["name"] : false
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# @return [Hash{String => Object}] the picture aggregate as the editor's schema names it
|
|
76
|
+
def describe(agg, command, found)
|
|
77
|
+
listing = agg["queries"].find { |query| query["attributes"].empty? }
|
|
78
|
+
{ "aggregate" => agg["name"], "command" => command["name"], "fields" => found,
|
|
79
|
+
"listing" => listing && listing["name"] }
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Hecks
|
|
6
|
+
module Projections
|
|
7
|
+
module Site
|
|
8
|
+
module CmsEditor
|
|
9
|
+
# JSON indented two spaces, with an empty list or object as `[]` or `{}`. It does not use
|
|
10
|
+
# `JSON.pretty_generate`, whose spelling of an empty list depends on which JSON extensions
|
|
11
|
+
# are loaded, so the projection's text is the same wherever it runs.
|
|
12
|
+
module Pretty
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
# @param value [Object] a hash, array, string, number, boolean or nil
|
|
16
|
+
# @param depth [Integer] the indentation level of the line the value starts on
|
|
17
|
+
# @return [String] the value as indented JSON
|
|
18
|
+
def generate(value, depth = 0)
|
|
19
|
+
case value
|
|
20
|
+
when Hash then block("{", "}", value.map { |key, item| member(key, item, depth) }, depth)
|
|
21
|
+
when Array then block("[", "]", value.map { |item| generate(item, depth + 1) }, depth)
|
|
22
|
+
else JSON.generate(value)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# @return [String] one `"key": value` line of an object
|
|
27
|
+
def member(key, item, depth) = "#{JSON.generate(key.to_s)}: #{generate(item, depth + 1)}"
|
|
28
|
+
|
|
29
|
+
# @return [String] the lines between `open` and `close`, each indented under `depth`
|
|
30
|
+
def block(open, close, lines, depth)
|
|
31
|
+
return "#{open}#{close}" if lines.empty?
|
|
32
|
+
|
|
33
|
+
pad = " " * (depth + 1)
|
|
34
|
+
"#{open}\n#{lines.map { |line| "#{pad}#{line}" }.join(",\n")}\n#{" " * depth}#{close}"
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "attributes"
|
|
4
|
+
require_relative "media"
|
|
5
|
+
|
|
6
|
+
module Hecks
|
|
7
|
+
module Projections
|
|
8
|
+
module Site
|
|
9
|
+
module CmsEditor
|
|
10
|
+
# What the editor reads of a domain chapter, as plain data: each aggregate with its
|
|
11
|
+
# identity, attributes, value objects, lifecycle, commands and queries. The editor's
|
|
12
|
+
# `schema.ts` is this hash as a typed constant, and nothing else about the domain reaches
|
|
13
|
+
# the generated files, so the output is a pure function of the chapter.
|
|
14
|
+
#
|
|
15
|
+
# An aggregate that registers pictures (see `Media`) is also named under `media`, so the
|
|
16
|
+
# editor can offer an upload; a chapter with none has no such key.
|
|
17
|
+
#
|
|
18
|
+
# A query that `returns` a value object is answered outside the domain and is left out: the
|
|
19
|
+
# host refuses it, so a list cannot be built from it.
|
|
20
|
+
class Schema
|
|
21
|
+
# @param chapter [Bluebook::Chapter] the domain's chapter
|
|
22
|
+
# @param skip [Array<String>] aggregates to leave out
|
|
23
|
+
# @return [Hash{String => Object}] the domain's name and its aggregates
|
|
24
|
+
# @raise [ArgumentError] when no aggregate is left
|
|
25
|
+
def self.read(chapter, skip: [])
|
|
26
|
+
aggregates = chapter.aggregates.reject { |agg| skip.include?(agg.hecks_name) }
|
|
27
|
+
raise ArgumentError, "the #{chapter.name} chapter has no aggregate to edit" if aggregates.empty?
|
|
28
|
+
|
|
29
|
+
shaped = aggregates.map { |agg| new(agg).to_h }
|
|
30
|
+
media = Media.read(shaped)
|
|
31
|
+
{ "domain" => chapter.name, "aggregates" => shaped, **(media ? { "media" => media } : {}) }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# @param aggregate [Bluebook::Aggregate] one aggregate of the chapter
|
|
35
|
+
def initialize(aggregate)
|
|
36
|
+
@agg = aggregate
|
|
37
|
+
@attributes = Attributes.new(aggregate.value_objects)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# @return [Hash{String => Object}] the aggregate as the editor reads it
|
|
41
|
+
def to_h
|
|
42
|
+
{ "name" => @agg.hecks_name, "description" => @agg.description, "identity" => @agg.identified_by.to_s,
|
|
43
|
+
"lifecycle" => lifecycle, "attributes" => attributes(@agg.attributes.reject { |a| a.name == lifecycle_field }),
|
|
44
|
+
"valueObjects" => value_objects, "commands" => @agg.commands.map { |command| command(command) },
|
|
45
|
+
"queries" => queries }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def lifecycle_field = @agg.lifecycle&.field
|
|
51
|
+
|
|
52
|
+
def attributes(list) = list.map { |attribute| @attributes.of(attribute) }
|
|
53
|
+
|
|
54
|
+
def value_objects
|
|
55
|
+
@agg.value_objects.to_h { |object| [object.hecks_name, attributes(object.attributes)] }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def lifecycle
|
|
59
|
+
lifecycle = @agg.lifecycle
|
|
60
|
+
return nil unless lifecycle
|
|
61
|
+
|
|
62
|
+
{ "field" => lifecycle.field.to_s, "default" => lifecycle.default,
|
|
63
|
+
"transitions" => lifecycle.transitions.map { |verb, move| transition(verb, move) } }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def transition(verb, move)
|
|
67
|
+
{ "verb" => verb, "from" => Array(move.from), "to" => move.target }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def command(command)
|
|
71
|
+
{ "name" => command.hecks_name, "goal" => command.goal, "role" => command.role, "creates" => command.creates?,
|
|
72
|
+
"on" => command.creates? ? nil : command.references.to_s, "attributes" => attributes(command.attributes) }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def queries
|
|
76
|
+
@agg.queries.reject(&:returns).map do |query|
|
|
77
|
+
{ "name" => query.name, "description" => query.description, "attributes" => attributes(query.attributes) }
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "../root_rows"
|
|
5
|
+
require_relative "../routes_ts"
|
|
6
|
+
require_relative "checks"
|
|
7
|
+
|
|
8
|
+
module Hecks
|
|
9
|
+
module Projections
|
|
10
|
+
module Site
|
|
11
|
+
module CmsEditor
|
|
12
|
+
# The project's `Editor` row, checked: which domain chapter the editor edits, where the
|
|
13
|
+
# editor is served, the session cookie it keeps, and the host and sign-in it reads.
|
|
14
|
+
#
|
|
15
|
+
# `docs/site-routes.md` lists the fields. A project that declares no row has no editor to
|
|
16
|
+
# project.
|
|
17
|
+
class Setting
|
|
18
|
+
# The `Editor` row: its fields, which are required and what the others default to.
|
|
19
|
+
ROW = RootRows.new(
|
|
20
|
+
"Editor",
|
|
21
|
+
fields: { domain: String, chapter: String, base_path: String, sso_path: String, session_cookie: String,
|
|
22
|
+
host_cookie: String, host_env: String, host_default: String, roles: String, login: String,
|
|
23
|
+
title: String, skip: String, media_dir: String, media_max_bytes: String },
|
|
24
|
+
required: %i[domain chapter host_env login],
|
|
25
|
+
defaults: { base_path: "/editor", session_cookie: "hecks_editor", host_cookie: "hecks_session",
|
|
26
|
+
host_default: "http://127.0.0.1:4322", roles: "Admin,Owner", title: "Editor", skip: "",
|
|
27
|
+
media_dir: "media", media_max_bytes: "5242880" }
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
# @return [Hash{Symbol => String}] the checked row, defaults filled
|
|
31
|
+
attr_reader :row
|
|
32
|
+
|
|
33
|
+
# Reads and checks the row a chapter declares.
|
|
34
|
+
#
|
|
35
|
+
# @param chapter [Bluebook::Chapter] the chapter that declares the route table
|
|
36
|
+
# @param table [Table, nil] the checked route table, to check the login page against
|
|
37
|
+
# @return [Setting, nil] the checked setting, or nil when the chapter declares no row
|
|
38
|
+
# @raise [Table::Invalid] when the row is refused
|
|
39
|
+
def self.read(chapter, table: nil)
|
|
40
|
+
row = ROW.read(chapter).first
|
|
41
|
+
row && new(row, table: table)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# @param row [Hash{Symbol => String}] the row, defaults filled
|
|
45
|
+
# @param table [Table, nil] the checked route table
|
|
46
|
+
# @raise [Table::Invalid] naming every problem when there is one
|
|
47
|
+
def initialize(row, table: nil)
|
|
48
|
+
@row = { sso_path: "#{row[:base_path]}/api/sso" }.merge(row)
|
|
49
|
+
problems = Checks.problems(@row, roles, table)
|
|
50
|
+
return if problems.empty?
|
|
51
|
+
|
|
52
|
+
raise Table::Invalid, "the Editor row is refused:\n#{problems.map { |line| " - #{line}" }.join("\n")}"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# @return [String] the domain's directory, relative to the project
|
|
56
|
+
def domain = row.fetch(:domain)
|
|
57
|
+
|
|
58
|
+
# @return [String] the chapter the editor edits
|
|
59
|
+
def chapter = row.fetch(:chapter)
|
|
60
|
+
|
|
61
|
+
# @return [Array<String>] the aggregates left out
|
|
62
|
+
def skip = row.fetch(:skip).split(",").map(&:strip).reject(&:empty?)
|
|
63
|
+
|
|
64
|
+
# @return [Array<String>] the roles that count as an editor
|
|
65
|
+
def roles = row.fetch(:roles).split(",").map(&:strip).reject(&:empty?)
|
|
66
|
+
|
|
67
|
+
# @return [String] the generated package's name
|
|
68
|
+
def package = "#{chapter.gsub(/([a-z])([A-Z])/, '\1-\2').tr("_", "-").downcase}-editor"
|
|
69
|
+
|
|
70
|
+
# @return [Hash{String => String}] each placeholder to the text that replaces it
|
|
71
|
+
def tokens
|
|
72
|
+
{ "__BANNER__" => RoutesTs::BANNER, "__PACKAGE__" => package, "__ROLES__" => RoutesTs.literal(roles),
|
|
73
|
+
"__MEDIA_MAX_BYTES__" => row.fetch(:media_max_bytes), **quoted }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# @return [Hash{String => String}] each text field's placeholder to the field as JSON
|
|
77
|
+
def quoted
|
|
78
|
+
{ "TITLE" => :title, "BASE_PATH" => :base_path, "SSO_PATH" => :sso_path, "LOGIN" => :login,
|
|
79
|
+
"SESSION_COOKIE" => :session_cookie, "HOST_COOKIE" => :host_cookie, "HOST_ENV" => :host_env,
|
|
80
|
+
"HOST_DEFAULT" => :host_default, "MEDIA_DIR" => :media_dir }
|
|
81
|
+
.to_h { |token, field| ["__#{token}__", JSON.generate(row.fetch(field))] }
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "__PACKAGE__",
|
|
3
|
+
"private": true,
|
|
4
|
+
"type": "module",
|
|
5
|
+
"engines": {
|
|
6
|
+
"node": ">=22.18"
|
|
7
|
+
},
|
|
8
|
+
"scripts": {
|
|
9
|
+
"start": "node src/server.ts",
|
|
10
|
+
"check": "tsc -p tsconfig.json"
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"@hecks/client": "^3.8.0"
|
|
14
|
+
},
|
|
15
|
+
"devDependencies": {
|
|
16
|
+
"@types/node": "^22.10.0",
|
|
17
|
+
"typescript": "^5.8.0"
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
__BANNER__
|
|
2
|
+
|
|
3
|
+
// The editor as a function from a web `Request` to a `Response`, with its host client, clock and
|
|
4
|
+
// secret passed in, so it runs the same under src/server.ts and under a test with a fake `fetch`.
|
|
5
|
+
//
|
|
6
|
+
// Every request is authenticated here: a signed session cookie, then the host's membership list
|
|
7
|
+
// (auth/membership.ts). A visitor with neither is sent to the site's login page; nothing in the
|
|
8
|
+
// domain is read or changed before that. Posts must come from this site (the cookie is SameSite=Lax
|
|
9
|
+
// and the Origin header is checked too).
|
|
10
|
+
import { readFileSync } from "node:fs";
|
|
11
|
+
|
|
12
|
+
import { DomainRefusal, DomainUnavailable, rowsOf } from "@hecks/client";
|
|
13
|
+
import type { HostClient } from "@hecks/client";
|
|
14
|
+
|
|
15
|
+
import { authSecret, membershipCheck } from "./auth/membership.ts";
|
|
16
|
+
import type { Membership } from "./auth/membership.ts";
|
|
17
|
+
import { readSession } from "./auth/session.ts";
|
|
18
|
+
import { signIn } from "./auth/sso.ts";
|
|
19
|
+
import { idOf, instanceIn, runCommand } from "./commands.ts";
|
|
20
|
+
import type { State } from "./commands.ts";
|
|
21
|
+
import { EDITOR } from "./config.ts";
|
|
22
|
+
import { hostClient } from "./host.ts";
|
|
23
|
+
import { SCHEMA } from "./schema.ts";
|
|
24
|
+
import type { Aggregate, Command } from "./schema.ts";
|
|
25
|
+
import { commandArguments, parseForm } from "./ui/input.ts";
|
|
26
|
+
import { commandPage, detailPage, home, listPage, notFound } from "./ui/pages.ts";
|
|
27
|
+
import type { Submitted } from "./ui/pages.ts";
|
|
28
|
+
import { href } from "./ui/html.ts";
|
|
29
|
+
__MEDIA_IMPORT__
|
|
30
|
+
|
|
31
|
+
export interface AppOptions {
|
|
32
|
+
/** The host's address, when not read from the environment variable the Editor row names. */
|
|
33
|
+
url?: string;
|
|
34
|
+
fetch?: typeof fetch;
|
|
35
|
+
secret?: string;
|
|
36
|
+
now?: () => number;
|
|
37
|
+
membership?: Membership;
|
|
38
|
+
__MEDIA_OPTION__
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const html = (body: string, status = 200): Response =>
|
|
42
|
+
new Response(body, { status, headers: { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" } });
|
|
43
|
+
|
|
44
|
+
const redirect = (location: string, status = 303, cookie?: string): Response => {
|
|
45
|
+
const headers = new Headers({ Location: location });
|
|
46
|
+
if (cookie) headers.append("Set-Cookie", cookie);
|
|
47
|
+
return new Response(null, { status, headers });
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/** A post is refused when the browser says it came from another site. */
|
|
51
|
+
function sameSite(request: Request): boolean {
|
|
52
|
+
const origin = request.headers.get("origin");
|
|
53
|
+
if (origin) return new URL(origin).host === new URL(request.url).host;
|
|
54
|
+
return (request.headers.get("sec-fetch-site") ?? "same-origin") !== "cross-site";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The editor, as a handler. */
|
|
58
|
+
export function createApp(options: AppOptions = {}): (request: Request) => Promise<Response> {
|
|
59
|
+
const secret = options.secret ?? authSecret();
|
|
60
|
+
const now = options.now ?? Date.now;
|
|
61
|
+
const membership = options.membership ?? membershipCheck({ url: options.url, fetch: options.fetch, secret, now });
|
|
62
|
+
const client = hostClient({ url: options.url, fetch: options.fetch });
|
|
63
|
+
__MEDIA_SETUP__
|
|
64
|
+
|
|
65
|
+
async function handle(request: Request): Promise<Response> {
|
|
66
|
+
const url = new URL(request.url);
|
|
67
|
+
const path = url.pathname.replace(/\/+$/, "") || "/";
|
|
68
|
+
if (path === EDITOR.ssoPath) return enter(url, secret, membership, now);
|
|
69
|
+
if (path !== EDITOR.basePath && !path.startsWith(`${EDITOR.basePath}/`)) return html(notFound(path), 404);
|
|
70
|
+
|
|
71
|
+
const email = readSession(request.headers.get("cookie"), secret, now);
|
|
72
|
+
if (!email || !(await membership.isAdmitted(email))) {
|
|
73
|
+
return request.method === "GET" ? redirect(EDITOR.login, 302) : new Response("Sign in again.", { status: 403 });
|
|
74
|
+
}
|
|
75
|
+
if (request.method === "POST" && !sameSite(request)) return new Response("Cross-site post refused.", { status: 403 });
|
|
76
|
+
const segments = path.slice(EDITOR.basePath.length).split("/").filter(Boolean).map(decodeURIComponent);
|
|
77
|
+
__MEDIA_ROUTE__
|
|
78
|
+
return route(client, request, url, segments);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return async (request) => {
|
|
82
|
+
try {
|
|
83
|
+
return await handle(request);
|
|
84
|
+
} catch (err) {
|
|
85
|
+
if (err instanceof DomainUnavailable) return html(notFound(`The domain could not be reached: ${err.message}`), 502);
|
|
86
|
+
throw err;
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The browser modules the rich-text widget loads (with the picture picker when there are pictures), for signed-in editors only. */
|
|
92
|
+
const ASSETS = __ASSETS__;
|
|
93
|
+
|
|
94
|
+
function asset(name: string): Response {
|
|
95
|
+
if (!ASSETS.includes(name)) return html(notFound(`No asset ${name}.`), 404);
|
|
96
|
+
const text = readFileSync(new URL(`./ui/${name}`, import.meta.url), "utf8");
|
|
97
|
+
return new Response(text, { headers: { "Content-Type": "text/javascript; charset=utf-8", "Cache-Control": "private, max-age=300" } });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function enter(url: URL, secret: string, membership: Membership, now: () => number): Promise<Response> {
|
|
101
|
+
const result = await signIn(url.searchParams.get("token"), secret, membership, now);
|
|
102
|
+
if (!result.ok) return new Response(result.message, { status: result.status });
|
|
103
|
+
return redirect(result.location, 302, result.cookie);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function route(client: HostClient, request: Request, url: URL, segments: string[]): Promise<Response> | Response {
|
|
107
|
+
const [name, verb, ...rest] = segments;
|
|
108
|
+
if (segments.length === 0) return html(home());
|
|
109
|
+
if (name === "assets" && segments.length === 2 && request.method === "GET") return asset(verb);
|
|
110
|
+
const agg = SCHEMA.aggregates.find((candidate) => candidate.name === name);
|
|
111
|
+
if (!agg) return html(notFound(`No aggregate ${name}.`), 404);
|
|
112
|
+
if (verb === undefined) return list(client, agg, url);
|
|
113
|
+
if (verb === "id" && rest.length === 1 && request.method === "GET") return detail(client, agg, rest[0], null);
|
|
114
|
+
if (request.method === "POST") return post(client, request, agg, verb, rest);
|
|
115
|
+
if ((verb === "new" || verb === "run") && rest.length === 1) return commandView(agg, verb, rest[0]);
|
|
116
|
+
return html(notFound(`No page ${segments.join("/")}.`), 404);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function list(client: HostClient, agg: Aggregate, url: URL): Promise<Response> {
|
|
120
|
+
const query = agg.queries.find((candidate) => candidate.name === url.searchParams.get("query")) ?? null;
|
|
121
|
+
const form = parseForm(url.search.slice(1));
|
|
122
|
+
const args = query ? commandArguments(agg, query.attributes, form) : {};
|
|
123
|
+
const asked = query !== null && (query.attributes.length === 0 || Object.keys(args).length > 0);
|
|
124
|
+
if (query && !asked) return html(listPage(agg, [], query, null));
|
|
125
|
+
try {
|
|
126
|
+
const rows = await rowsFor(client, agg, asked ? query : null, args);
|
|
127
|
+
return html(listPage(agg, rows, query, null));
|
|
128
|
+
} catch (err) {
|
|
129
|
+
if (err instanceof DomainRefusal) return html(listPage(agg, [], query, err.message), 200);
|
|
130
|
+
throw err;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function rowsFor(client: HostClient, agg: Aggregate, query: { name: string } | null, args: State): Promise<[string, State][]> {
|
|
135
|
+
if (!query) return client.instancesOf(await client.read(), agg.name);
|
|
136
|
+
const rows = rowsOf(await client.query(`${agg.name}.${query.name}`, args), `${agg.name}.${query.name}`) ?? [];
|
|
137
|
+
return rows.flatMap((row): [string, State][] => {
|
|
138
|
+
const id = idOf(agg, row);
|
|
139
|
+
return id === null ? [] : [[id, row]];
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function detail(client: HostClient, agg: Aggregate, id: string, shown: Submitted | null, status = 200): Promise<Response> {
|
|
144
|
+
const values = instanceIn(client, await client.read(), agg, id);
|
|
145
|
+
if (!values) return html(notFound(`No ${agg.name} ${id}.`), 404);
|
|
146
|
+
return html(detailPage(agg, id, values, shown), status);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function commandView(agg: Aggregate, verb: string, name: string): Response {
|
|
150
|
+
const command = commandFor(agg, verb, name);
|
|
151
|
+
if (!command) return html(notFound(`No command ${name}.`), 404);
|
|
152
|
+
return html(commandPage(agg, command, href(agg.name, verb, name), null));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** The command a `new` (creating) or `run` (acting on another aggregate) path names, or null. */
|
|
156
|
+
function commandFor(agg: Aggregate, verb: string, name: string): Command | null {
|
|
157
|
+
const command = agg.commands.find((candidate) => candidate.name === name);
|
|
158
|
+
if (!command) return null;
|
|
159
|
+
if (verb === "new") return command.creates ? command : null;
|
|
160
|
+
if (verb === "run") return !command.creates && command.on !== agg.name ? command : null;
|
|
161
|
+
return command.on === agg.name ? command : null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function post(client: HostClient, request: Request, agg: Aggregate, verb: string, rest: string[]): Promise<Response> {
|
|
165
|
+
const onInstance = verb === "id" && rest.length === 2;
|
|
166
|
+
const command = onInstance ? commandFor(agg, "id", rest[1]) : rest.length === 1 ? commandFor(agg, verb, rest[0]) : null;
|
|
167
|
+
if (!command) return html(notFound("No such command."), 404);
|
|
168
|
+
const form = parseForm(await request.text());
|
|
169
|
+
const args = commandArguments(agg, command.attributes, form);
|
|
170
|
+
const to = onInstance ? rest[0] : command.creates ? undefined : (form.get("__to") ?? "");
|
|
171
|
+
const outcome = to === "" ? { ok: false as const, message: "Name the instance to act on." } : await runCommand(client, agg, command, args, to);
|
|
172
|
+
if (outcome.ok) return redirect(href(agg.name, "id", outcome.id));
|
|
173
|
+
const shown: Submitted = { command: command.name, message: outcome.message, values: { ...args, __to: to } };
|
|
174
|
+
if (onInstance) return detail(client, agg, rest[0], shown, 422);
|
|
175
|
+
return html(commandPage(agg, command, href(agg.name, verb, command.name), shown), 422);
|
|
176
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
__BANNER__
|
|
2
|
+
|
|
3
|
+
// Is this person still an editor? The domain's membership list decides, not this server. Signing in
|
|
4
|
+
// (auth/sso.ts) proved the person was admitted then; this asks again on every request, so that
|
|
5
|
+
// disabling or removing someone locks them out at once, not when their session runs out.
|
|
6
|
+
//
|
|
7
|
+
// The question is the host's members list, asked with a short account token signed by the secret the
|
|
8
|
+
// host checks (AUTH_SECRET here is the host's SESSION_SECRET). The person counts when the list holds
|
|
9
|
+
// their email with one of the configured roles and not disabled. The answer is remembered for a
|
|
10
|
+
// minute per email so each request does not cost a round trip; a question that cannot be asked is
|
|
11
|
+
// answered no.
|
|
12
|
+
import { accountToken, resolveAccountCookieName } from "@hecks/client";
|
|
13
|
+
|
|
14
|
+
import { EDITOR } from "../config.ts";
|
|
15
|
+
import { hostUrl } from "../host.ts";
|
|
16
|
+
|
|
17
|
+
const TOKEN_TTL_SECONDS = 60;
|
|
18
|
+
|
|
19
|
+
/** The secret shared with the host. Outside production it falls back to the host's development default. */
|
|
20
|
+
export function authSecret(): string {
|
|
21
|
+
return process.env.AUTH_SECRET || (process.env.NODE_ENV === "production" ? "" : "dev_only_change_me_in_production");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface Membership {
|
|
25
|
+
/** True while `email` holds an editor role on the host; false when it does not, or cannot be asked. */
|
|
26
|
+
isAdmitted(email: string): Promise<boolean>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface MembershipOptions {
|
|
30
|
+
url?: string;
|
|
31
|
+
secret?: string;
|
|
32
|
+
fetch?: typeof fetch;
|
|
33
|
+
now?: () => number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** True when the host's members list holds `email` with an editor role and not disabled. */
|
|
37
|
+
export function holdsRole(rows: unknown, email: string): boolean {
|
|
38
|
+
const roles: readonly string[] = EDITOR.roles;
|
|
39
|
+
return (
|
|
40
|
+
Array.isArray(rows) &&
|
|
41
|
+
rows.some(
|
|
42
|
+
(row: any) =>
|
|
43
|
+
typeof row?.email === "string" && row.email.trim().toLowerCase() === email && roles.includes(row.role) && row.disabled !== true,
|
|
44
|
+
)
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** A membership check with its own one-minute memory. */
|
|
49
|
+
export function membershipCheck(options: MembershipOptions = {}): Membership {
|
|
50
|
+
const remembered = new Map<string, { admitted: boolean; until: number }>();
|
|
51
|
+
const now = options.now ?? Date.now;
|
|
52
|
+
const send = options.fetch ?? fetch;
|
|
53
|
+
|
|
54
|
+
async function ask(email: string): Promise<boolean> {
|
|
55
|
+
const secret = options.secret ?? authSecret();
|
|
56
|
+
if (!secret) return false;
|
|
57
|
+
const cookieName = resolveAccountCookieName(process.env.HECKS_SESSION_COOKIE || EDITOR.hostCookie);
|
|
58
|
+
try {
|
|
59
|
+
const res = await send(`${hostUrl({ url: options.url })}${EDITOR.membersPath}`, {
|
|
60
|
+
headers: { Cookie: `${cookieName}=${accountToken(secret, email, TOKEN_TTL_SECONDS)}` },
|
|
61
|
+
signal: AbortSignal.timeout(EDITOR.timeoutMs),
|
|
62
|
+
});
|
|
63
|
+
return res.ok && holdsRole(await res.json().catch(() => null), email);
|
|
64
|
+
} catch {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
async isAdmitted(email: string): Promise<boolean> {
|
|
71
|
+
const key = email.trim().toLowerCase();
|
|
72
|
+
const hit = remembered.get(key);
|
|
73
|
+
if (hit && hit.until > now()) return hit.admitted;
|
|
74
|
+
const admitted = await ask(key);
|
|
75
|
+
remembered.set(key, { admitted, until: now() + EDITOR.rememberMs });
|
|
76
|
+
return admitted;
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|