hecks 3.4.1 → 3.5.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/memory/shared_elements.rb +121 -17
- data/lib/hecks/deploy/bluebook/deploy.bluebook +1630 -0
- data/lib/hecks/deploy/bluebook/deploy.hecksagon +65 -0
- data/lib/hecks/deploy/oidc.json +240 -0
- data/lib/hecks/doors/cli_runner.rb +13 -4
- data/lib/hecks/doors/launcher_options.rb +14 -2
- data/lib/hecks/hecks/adapters/bluebook_report.rb +91 -0
- data/lib/hecks/hecks/adapters/deploy_plans.rb +49 -0
- data/lib/hecks/hecks/adapters/deploy_toolchain.rb +379 -8
- data/lib/hecks/hecks/adapters/first_admin.rb +8 -1
- data/lib/hecks/hecks/adapters/git.rb +29 -0
- data/lib/hecks/hecks/adapters/in_process_operations.rb +6 -2
- data/lib/hecks/hecks/adapters/site_toolchain.rb +8 -8
- data/lib/hecks/hecks/custodian.bluebook +23 -2
- data/lib/hecks/hecks/hecks.hecksagon +66 -0
- data/lib/hecks/hecks/hecks.world +6 -1
- data/lib/hecks/hecks/site.world +4 -2
- data/lib/hecks/projections/deploy/box/hosting.rb +13 -3
- data/lib/hecks/projections/deploy/box/roll_recipe.rb +62 -0
- data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +10 -3
- data/lib/hecks/projections/deploy/box/templates/deploy-service.services-roll.part +2 -2
- data/lib/hecks/projections/deploy/box/templates/deploy-service.sh.tmpl +16 -4
- data/lib/hecks/projections/deploy/box/templates/deploy-service.taskdef-roll.part +6 -6
- data/lib/hecks/projections/deploy/box/templates/deploy-service.taskdef.part +1 -1
- data/lib/hecks/projections/deploy/box/templates/hosting.mk.tmpl +11 -5
- data/lib/hecks/projections/deploy/box/templates/restore-to-rds.sh.tmpl +8 -3
- data/lib/hecks/projections/deploy/box/templates/verify-copy.sh.tmpl +2 -2
- data/lib/hecks/projections/deploy/box.rb +26 -13
- data/lib/hecks/projections/site/admin.rb +140 -0
- data/lib/hecks/projections/site/admin_cms/templates.rb +388 -0
- data/lib/hecks/projections/site/admin_cms.rb +72 -0
- data/lib/hecks/projections/site/payload/fields.rb +234 -0
- data/lib/hecks/projections/site/payload/lifecycle.rb +191 -0
- data/lib/hecks/projections/site/payload/model.rb +128 -0
- data/lib/hecks/projections/site/payload/specs.rb +179 -0
- data/lib/hecks/projections/site/payload_driver.rb +93 -0
- data/lib/hecks/projections/site/root_rows.rb +59 -0
- data/lib/hecks/projections/site/site_admin.rb +292 -0
- data/lib/hecks/projections/site/site_host/templates.rb +165 -0
- data/lib/hecks/projections/site/site_host.rb +58 -0
- data/lib/hecks/projections/site/site_root.rb +179 -0
- data/lib/hecks/runtime/value.rb +7 -0
- data/lib/hecks/site/bluebook/site.bluebook +13 -2
- data/lib/hecks/tools/site_routes.rb +78 -8
- data/lib/hecks/version.rb +1 -1
- data/rust/host/HECKS_RELEASE +1 -1
- metadata +18 -2
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Hecks
|
|
6
|
+
module Projections
|
|
7
|
+
module Site
|
|
8
|
+
module Payload
|
|
9
|
+
# The TypeScript the content system drives each aggregate with: its input type, how an input
|
|
10
|
+
# goes over the wire, how the host's state reads back into one, and the lifecycle the domain
|
|
11
|
+
# allows. The decisions that turn these into commands are in the lifecycle module, which the
|
|
12
|
+
# specs only describe.
|
|
13
|
+
module Specs
|
|
14
|
+
BANNER = "// Generated by hecks site site_projection.project_site. Do not edit."
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
# @param aggregates [Array<Model::Aggregate>] the aggregates to drive
|
|
19
|
+
# @param lifecycle [String] the import path of the lifecycle module
|
|
20
|
+
# @param hecks [String] the import path of the module that talks to the host
|
|
21
|
+
# @return [String] the module
|
|
22
|
+
def render(aggregates, lifecycle:, hecks:)
|
|
23
|
+
body = aggregates.map { |agg| aggregate(agg) }.join("\n\n")
|
|
24
|
+
[BANNER, header(aggregates, lifecycle, hecks), helpers(aggregates), body].reject(&:empty?).join("\n\n") << "\n"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def header(aggregates, lifecycle, hecks)
|
|
28
|
+
imports = %w[instancesOf] + (uses?(aggregates, :integer) ? %w[whole] : [])
|
|
29
|
+
names = %w[optionalValue statusOf unwrap unwrapList wrapped] + %w[type\ Instance type\ Spec type\ Wire]
|
|
30
|
+
<<~TS.chomp
|
|
31
|
+
import { #{imports.join(', ')} } from #{hecks.to_json};
|
|
32
|
+
import { #{names.join(', ')} } from #{lifecycle.to_json};
|
|
33
|
+
TS
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def helpers(aggregates)
|
|
37
|
+
<<~TS.chomp
|
|
38
|
+
const list = (items: string[]) => items.map(wrapped);
|
|
39
|
+
const orNull = (value: unknown) => unwrap(value);#{composite_reader(aggregates)}
|
|
40
|
+
TS
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def composite_reader(aggregates)
|
|
44
|
+
return "" unless uses?(aggregates, :composite)
|
|
45
|
+
|
|
46
|
+
<<~TS.chomp.prepend("\n")
|
|
47
|
+
const text = (value: unknown) => (typeof value === "string" ? value : "");
|
|
48
|
+
TS
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def uses?(aggregates, shape) = aggregates.any? { |agg| agg.attrs.any? { |attr| attr.shape == shape } }
|
|
52
|
+
|
|
53
|
+
def aggregate(agg)
|
|
54
|
+
[heading(agg), composites(agg), input_type(agg), state_type(agg), composite_readers(agg), spec(agg)]
|
|
55
|
+
.reject(&:empty?).join("\n\n")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def heading(agg) = "// ---- #{agg.noun} ----"
|
|
59
|
+
|
|
60
|
+
def composites(agg)
|
|
61
|
+
agg.attrs.select { |attr| attr.shape == :composite }.map do |attr|
|
|
62
|
+
fields = attr.parts.map { |part| " #{part.ts}: string;" }.join("\n")
|
|
63
|
+
"export interface #{attr.type} {\n#{fields}\n}"
|
|
64
|
+
end.join("\n\n")
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def input_type(agg)
|
|
68
|
+
fields = agg.attrs.map { |attr| " #{attr.ts}: #{ts_type(attr)};" }.join("\n")
|
|
69
|
+
"export interface #{agg.noun}Input {\n#{fields}\n}"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def state_type(agg) = "export type #{agg.noun}State = #{agg.noun}Input & Instance;"
|
|
73
|
+
|
|
74
|
+
def ts_type(attr)
|
|
75
|
+
base = if attr.shape == :integer
|
|
76
|
+
"number"
|
|
77
|
+
else
|
|
78
|
+
(attr.shape == :composite ? attr.type : "string")
|
|
79
|
+
end
|
|
80
|
+
return "#{base}[]" if attr.list
|
|
81
|
+
|
|
82
|
+
attr.optional ? "#{base} | null" : base
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def composite_readers(agg)
|
|
86
|
+
agg.attrs.select { |attr| attr.shape == :composite }.map do |attr|
|
|
87
|
+
parts = attr.parts.map { |part| "#{part.ts}: text(s.#{part.name})" }.join(", ")
|
|
88
|
+
<<~TS.chomp
|
|
89
|
+
const #{reader(agg, attr)} = (value: unknown): #{attr.type}[] =>
|
|
90
|
+
Array.isArray(value) ? value.map((raw) => { const s = (raw ?? {}) as Record<string, unknown>; return { #{parts} }; }) : [];
|
|
91
|
+
TS
|
|
92
|
+
end.join("\n\n")
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def reader(agg, attr) = "#{agg.noun[0].downcase}#{agg.noun[1..]}#{attr.ts[0].upcase}#{attr.ts[1..]}From"
|
|
96
|
+
|
|
97
|
+
def spec(agg)
|
|
98
|
+
var = "#{agg.noun[0].downcase}#{agg.noun[1..]}"
|
|
99
|
+
<<~TS.chomp
|
|
100
|
+
export const #{var}Spec: Spec<#{agg.noun}State, #{agg.noun}Input> = {
|
|
101
|
+
aggregate: #{agg.fqn.to_json},
|
|
102
|
+
read: (answer) =>
|
|
103
|
+
instancesOf(answer, #{agg.fqn.to_json}).map(([#{agg.identity}, s]) => ({
|
|
104
|
+
#{agg.identity},
|
|
105
|
+
status: statusOf(s),
|
|
106
|
+
#{reads(agg)}
|
|
107
|
+
})),
|
|
108
|
+
toInput: ({ status: _status, ...input }) => input,
|
|
109
|
+
fields: (input): Wire => ({
|
|
110
|
+
#{writes(agg)}
|
|
111
|
+
}),
|
|
112
|
+
create: { verb: #{agg.create[:verb].to_json}, status: #{agg.create[:status].to_json} },
|
|
113
|
+
edges: #{edges(agg.edges)},
|
|
114
|
+
};
|
|
115
|
+
TS
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def others(agg) = agg.attrs.reject { |attr| attr.name == agg.identity }
|
|
119
|
+
|
|
120
|
+
def reads(agg)
|
|
121
|
+
others(agg).map { |attr| " #{attr.ts}: #{read(agg, attr)}," }.join("\n")
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def read(agg, attr)
|
|
125
|
+
return "#{reader(agg, attr)}(s.#{attr.name})" if attr.shape == :composite
|
|
126
|
+
return "unwrapList(s.#{attr.name})" if attr.list
|
|
127
|
+
return "whole(s.#{attr.name}, #{attr.wire_key.to_json})" if attr.shape == :integer
|
|
128
|
+
|
|
129
|
+
attr.optional ? "orNull(s.#{attr.name})" : "orNull(s.#{attr.name}) ?? \"\""
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def writes(agg)
|
|
133
|
+
others(agg).map { |attr| " #{write(attr)}," }.join("\n")
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def write(attr)
|
|
137
|
+
name = attr.name
|
|
138
|
+
case attr.shape
|
|
139
|
+
when :composite then composite_write(attr)
|
|
140
|
+
when :integer then optional_write(attr, "{ #{attr.wire_key}: input.#{attr.ts} }", "input.#{attr.ts} != null")
|
|
141
|
+
else
|
|
142
|
+
return "#{name}: list(input.#{attr.ts})" if attr.list
|
|
143
|
+
|
|
144
|
+
scalar_write(attr)
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def composite_write(attr)
|
|
149
|
+
parts = attr.parts.map { |part| "#{part.name}: item.#{part.ts}" }.join(", ")
|
|
150
|
+
"#{attr.name}: input.#{attr.ts}.map((item) => ({ #{parts} }))"
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def scalar_write(attr)
|
|
154
|
+
if attr.wire_key == "value"
|
|
155
|
+
return "#{attr.name}: wrapped(input.#{attr.ts})" unless attr.optional
|
|
156
|
+
|
|
157
|
+
return "...optionalValue(#{attr.name.to_json}, input.#{attr.ts})"
|
|
158
|
+
end
|
|
159
|
+
optional_write(attr, "{ #{attr.wire_key}: input.#{attr.ts} }", "input.#{attr.ts}")
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def optional_write(attr, wire, present)
|
|
163
|
+
return "#{attr.name}: #{wire}" unless attr.optional
|
|
164
|
+
|
|
165
|
+
"...(#{present} ? { #{attr.name}: #{wire} } : {})"
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def edges(edges)
|
|
169
|
+
rows = edges.map do |status, moves|
|
|
170
|
+
inner = moves.map { |verb, target| "#{verb}: #{target.to_json}" }.join(", ")
|
|
171
|
+
" #{status}: { #{inner} },"
|
|
172
|
+
end
|
|
173
|
+
"{\n#{rows.join("\n")}\n }"
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
require_relative "../../projector"
|
|
5
|
+
require_relative "root_rows"
|
|
6
|
+
require_relative "payload/model"
|
|
7
|
+
require_relative "payload/specs"
|
|
8
|
+
require_relative "payload/fields"
|
|
9
|
+
require_relative "payload/lifecycle"
|
|
10
|
+
|
|
11
|
+
module Hecks
|
|
12
|
+
module Projections
|
|
13
|
+
module Site
|
|
14
|
+
# What the content system needs to drive a domain's aggregates, from the domain itself: the
|
|
15
|
+
# lifecycle module, a spec per aggregate (its input, its wire form, its lifecycle edges and
|
|
16
|
+
# the
|
|
17
|
+
# command that creates it) and the Payload field definitions with the readers that turn a
|
|
18
|
+
# saved
|
|
19
|
+
# document back into an input.
|
|
20
|
+
#
|
|
21
|
+
# A `Payload` row names the domain, its chapter and where the files go; `PayloadField` rows
|
|
22
|
+
# say what
|
|
23
|
+
# the editor needs that an attribute's shape cannot (a date picker, a choice list, an upload,
|
|
24
|
+
# a
|
|
25
|
+
# relation). The domain stays free of the content system: it is read, never annotated.
|
|
26
|
+
module PayloadDriver
|
|
27
|
+
extend Projector::Target
|
|
28
|
+
|
|
29
|
+
projects_as :payload_driver, emits: :files
|
|
30
|
+
|
|
31
|
+
# The `Payload` row.
|
|
32
|
+
PAYLOAD = RootRows.new("Payload",
|
|
33
|
+
fields: { domain: String, chapter: String, out: String, hecks: String, helpers: String,
|
|
34
|
+
skip: String },
|
|
35
|
+
required: %i[domain chapter],
|
|
36
|
+
defaults: { out: "cms/src/generated", hecks: "cms/src/driver/hecks",
|
|
37
|
+
helpers: "cms/src/collections/hooks/content", skip: "" })
|
|
38
|
+
|
|
39
|
+
# A `PayloadField` row: what the editor needs for one attribute (or `attribute.part` of a
|
|
40
|
+
# composite).
|
|
41
|
+
FIELD = RootRows.new("PayloadField",
|
|
42
|
+
fields: { aggregate: String, attribute: String, field: String, kind: String, options: String,
|
|
43
|
+
relation: String, via: String, label: String, description: String,
|
|
44
|
+
required: [TrueClass, FalseClass],
|
|
45
|
+
default: String },
|
|
46
|
+
required: %i[aggregate attribute], many: true)
|
|
47
|
+
|
|
48
|
+
module_function
|
|
49
|
+
|
|
50
|
+
# @param bluebook [Bluebook::Chapter] the chapter that declares the route table and the rows
|
|
51
|
+
# @param options [Hash{Symbol => Object}] `:domain_chapter` the domain's chapter, or nil
|
|
52
|
+
# @return [Hash{String => String}] each file's path relative to the project root to its
|
|
53
|
+
# text;
|
|
54
|
+
# empty when the project declares no `Payload` row
|
|
55
|
+
# @raise [Table::Invalid] when a row is refused
|
|
56
|
+
# @raise [ArgumentError] when an aggregate cannot be driven or a row names what is not there
|
|
57
|
+
def call(bluebook:, options: {})
|
|
58
|
+
row = PAYLOAD.read(bluebook).first
|
|
59
|
+
return {} unless row
|
|
60
|
+
|
|
61
|
+
chapter = options.fetch(:domain_chapter)
|
|
62
|
+
aggregates = Payload::Model.read(chapter, skip: row[:skip].split(",").map(&:strip))
|
|
63
|
+
raise ArgumentError, "the #{chapter.name} chapter has no aggregate with a lifecycle to drive" if aggregates.empty?
|
|
64
|
+
|
|
65
|
+
files(row, aggregates, FIELD.read(bluebook))
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def files(row, aggregates, rows)
|
|
69
|
+
out = row[:out]
|
|
70
|
+
roles = aggregates.map(&:role).uniq
|
|
71
|
+
if roles.size > 1
|
|
72
|
+
raise ArgumentError,
|
|
73
|
+
"the driven aggregates' commands declare roles #{roles.inspect}; the driver acts as one"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
from_driver = ->(path) { relative(path, "#{out}/driver") }
|
|
77
|
+
from_collections = ->(path) { relative(path, "#{out}/collections") }
|
|
78
|
+
{ "#{out}/driver/lifecycle.ts" => Payload::Lifecycle.render(role: roles.first, hecks: from_driver.call(row[:hecks])),
|
|
79
|
+
"#{out}/driver/specs.ts" => Payload::Specs.render(aggregates, lifecycle: "./lifecycle",
|
|
80
|
+
hecks: from_driver.call(row[:hecks])),
|
|
81
|
+
"#{out}/collections/fields.ts" => Payload::Fields.render(aggregates, rows: rows, specs: "../driver/specs",
|
|
82
|
+
helpers: from_collections.call(row[:helpers])) }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# @return [String] `path` as an import from `dir`, both relative to the project root
|
|
86
|
+
def relative(path, dir)
|
|
87
|
+
target = Pathname.new(path).relative_path_from(Pathname.new(dir)).to_s
|
|
88
|
+
target.start_with?(".") ? target : "./#{target}"
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "table"
|
|
4
|
+
|
|
5
|
+
module Hecks
|
|
6
|
+
module Projections
|
|
7
|
+
module Site
|
|
8
|
+
# Reads and checks the rows a project declares beside its route table for the files at the
|
|
9
|
+
# project's root, one row object at a time: the fields it may carry, the Ruby class each
|
|
10
|
+
# takes, which are required and what the others default to.
|
|
11
|
+
#
|
|
12
|
+
# A row that is refused raises `Table::Invalid` naming every problem, so a project sees all of
|
|
13
|
+
# them at once.
|
|
14
|
+
class RootRows
|
|
15
|
+
# @param object [String] the value object the rows are declared in
|
|
16
|
+
# @param fields [Hash{Symbol => Class, Array<Class>}] the fields a row may carry, by class
|
|
17
|
+
# @param required [Array<Symbol>] the fields a row must carry
|
|
18
|
+
# @param defaults [Hash{Symbol => Object}] what a field takes when the row omits it
|
|
19
|
+
# @param many [Boolean] whether the project may declare more than one row
|
|
20
|
+
def initialize(object, fields:, required: [], defaults: {}, many: false)
|
|
21
|
+
@object = object
|
|
22
|
+
@fields = fields
|
|
23
|
+
@required = required
|
|
24
|
+
@defaults = defaults
|
|
25
|
+
@many = many
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @param chapter [Bluebook::Chapter] the chapter that declares the route table
|
|
29
|
+
# @return [Array<Hash{Symbol => Object}>] each checked row, defaults filled; empty when the
|
|
30
|
+
# chapter declares none
|
|
31
|
+
# @raise [Table::Invalid] when a row is refused
|
|
32
|
+
def read(chapter)
|
|
33
|
+
members = Table.rows_of(chapter, @object)
|
|
34
|
+
problems = []
|
|
35
|
+
problems << "a project declares one #{@object} row, not #{members.size}" if members.size > 1 && !@many
|
|
36
|
+
rows = members.each_with_index.map { |member, index| check(member, index, problems) }
|
|
37
|
+
return rows if problems.empty?
|
|
38
|
+
|
|
39
|
+
raise Table::Invalid, "the #{@object} rows are refused:\n#{problems.map { |line| " - #{line}" }.join("\n")}"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def check(member, index, problems)
|
|
45
|
+
label = "#{@object} row #{index + 1}"
|
|
46
|
+
unknown = member.keys - @fields.keys
|
|
47
|
+
problems << "#{label} has no field #{unknown.join(', ')}; fields are #{@fields.keys.join(', ')}" if unknown.any?
|
|
48
|
+
(@required - member.keys).each { |field| problems << "#{label} needs #{field}" }
|
|
49
|
+
typed = member.slice(*@fields.keys).select do |field, value|
|
|
50
|
+
kinds = Array(@fields.fetch(field))
|
|
51
|
+
kinds.any? { |kind| value.is_a?(kind) } ||
|
|
52
|
+
(problems << "#{label} has #{field} #{value.inspect}; it is a #{kinds.join(' or ')}")
|
|
53
|
+
end
|
|
54
|
+
@defaults.merge(typed)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "../../projector"
|
|
5
|
+
require_relative "admin"
|
|
6
|
+
require_relative "routes_ts"
|
|
7
|
+
|
|
8
|
+
module Hecks
|
|
9
|
+
module Projections
|
|
10
|
+
module Site
|
|
11
|
+
# A site's admin sign-in as one dependency-free TypeScript module, `admin.ts`.
|
|
12
|
+
#
|
|
13
|
+
# The module is what a site's middleware and pages need to ask the domain host who is signed
|
|
14
|
+
# in and whether that person may use the admin pages: the session cookie's settings, a check
|
|
15
|
+
# that the host's membership list holds the person as an admin, a gate that decides a path
|
|
16
|
+
# from the route table's `auth` column, and the hand-off to the content system. It imports the
|
|
17
|
+
# route table module beside it and nothing else, so it loads under Node and any bundler.
|
|
18
|
+
#
|
|
19
|
+
# The text is a pure function of the `Admin` row and the extension: nothing in it names a
|
|
20
|
+
# time or a machine, so regenerating from the same row changes nothing.
|
|
21
|
+
module SiteAdmin
|
|
22
|
+
extend Projector::Target
|
|
23
|
+
|
|
24
|
+
projects_as :site_admin, emits: :files
|
|
25
|
+
|
|
26
|
+
module_function
|
|
27
|
+
|
|
28
|
+
# Renders `admin.ts` for a project that declares an admin row.
|
|
29
|
+
#
|
|
30
|
+
# @param bluebook [Bluebook::Chapter] the chapter that declares the route table
|
|
31
|
+
# @param options [Hash{Symbol => Object}] `:admin` (Admin) the checked admin row, or nil;
|
|
32
|
+
# `:extension` (String) the extension the routes module is written with, `ts` by default
|
|
33
|
+
# @return [Hash{String => String}] `"admin.ts"` to its text; empty with no admin row
|
|
34
|
+
def call(bluebook:, options: {})
|
|
35
|
+
admin = options[:admin]
|
|
36
|
+
return {} if admin.nil?
|
|
37
|
+
|
|
38
|
+
{ "admin.ts" => render(admin, options.fetch(:extension, "ts")) }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# @param admin [Admin] a checked admin row
|
|
42
|
+
# @param extension [String] the extension of the route table module
|
|
43
|
+
# @return [String] the module's text, ending in a newline
|
|
44
|
+
def render(admin, extension)
|
|
45
|
+
[RoutesTs::BANNER, header, imports(extension), settings(admin), memo, helpers].join("\n\n") << "\n"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def header
|
|
49
|
+
<<~TS.chomp
|
|
50
|
+
// The admin sign-in of this site, projected from its bluebook's Admin row and Route rows. Change the
|
|
51
|
+
// rows and run `hecks site site_projection.project_site`; with `--check` it fails when this file is out of date.
|
|
52
|
+
TS
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def imports(extension)
|
|
56
|
+
%(import { MIDDLEWARE, ROUTES, matchesPath } from "./routes.#{extension}";)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def settings(admin)
|
|
60
|
+
setting = admin.setting
|
|
61
|
+
fields = {
|
|
62
|
+
sessionCookie: setting.session_cookie, sessionMaxAge: setting.session_max_age,
|
|
63
|
+
hostEnv: setting.host_env, hostDefault: setting.host_default, roles: admin.roles,
|
|
64
|
+
accountPath: setting.account_path, membersPath: setting.members_path,
|
|
65
|
+
ssoTokenPath: setting.sso_token_path, ssoTarget: setting.sso_target, login: setting.login,
|
|
66
|
+
sso: setting.sso, verdictTtlMs: setting.verdict_ttl_ms, timeoutMs: setting.timeout_ms
|
|
67
|
+
}
|
|
68
|
+
body = fields.map { |key, value| " #{key}: #{RoutesTs.literal(value)}," }
|
|
69
|
+
"/** What the sign-in is built from: the project's Admin row, defaults filled. */\n" \
|
|
70
|
+
"export const ADMIN = {\n#{body.join("\n")}\n} as const;"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def memo
|
|
74
|
+
<<~TS.chomp
|
|
75
|
+
const MAX_REMEMBERED = 500;
|
|
76
|
+
|
|
77
|
+
/** Remembers each key's answer for the verdict TTL; callers that arrive while it loads share it, and a failure is never kept. */
|
|
78
|
+
function remember<V>(load: (key: string) => Promise<V>) {
|
|
79
|
+
const values = new Map<string, { at: number; value: V }>();
|
|
80
|
+
const running = new Map<string, Promise<V>>();
|
|
81
|
+
return {
|
|
82
|
+
get(key: string): Promise<V> {
|
|
83
|
+
const hit = values.get(key);
|
|
84
|
+
if (hit && Date.now() - hit.at < ADMIN.verdictTtlMs) return Promise.resolve(hit.value);
|
|
85
|
+
const shared = running.get(key);
|
|
86
|
+
if (shared) return shared;
|
|
87
|
+
const started: Promise<V> = load(key)
|
|
88
|
+
.then((value) => {
|
|
89
|
+
if (running.get(key) === started) {
|
|
90
|
+
values.delete(key);
|
|
91
|
+
values.set(key, { at: Date.now(), value });
|
|
92
|
+
if (values.size > MAX_REMEMBERED) values.delete(values.keys().next().value as string);
|
|
93
|
+
}
|
|
94
|
+
return value;
|
|
95
|
+
})
|
|
96
|
+
.finally(() => {
|
|
97
|
+
if (running.get(key) === started) running.delete(key);
|
|
98
|
+
});
|
|
99
|
+
running.set(key, started);
|
|
100
|
+
return started;
|
|
101
|
+
},
|
|
102
|
+
forget(): void {
|
|
103
|
+
values.clear();
|
|
104
|
+
running.clear();
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
TS
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def helpers
|
|
112
|
+
[configuration, membership, sessions, verdicts, handoff, routing, gate].join("\n\n")
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def configuration
|
|
116
|
+
<<~'TS'.chomp
|
|
117
|
+
export interface AdminSettings {
|
|
118
|
+
/** The domain host's address, when not read from the environment variable the Admin row names. */
|
|
119
|
+
host?: string;
|
|
120
|
+
/** The fetch to use, for tests. */
|
|
121
|
+
fetch?: typeof fetch;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
let settings: AdminSettings = {};
|
|
125
|
+
|
|
126
|
+
/** Sets the host or the fetch, and forgets every remembered verdict. */
|
|
127
|
+
export function configureAdmin(next: AdminSettings): void {
|
|
128
|
+
settings = next;
|
|
129
|
+
forgetAdminSessions();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function hostUrl(): string {
|
|
133
|
+
const named = settings.host ?? (typeof process !== "undefined" ? process.env[ADMIN.hostEnv] : undefined);
|
|
134
|
+
return (named || ADMIN.hostDefault).replace(/\/+$/, "");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function askHost(path: string, cookieValue: string): Promise<Response> {
|
|
138
|
+
return (settings.fetch ?? fetch)(`${hostUrl()}${path}`, {
|
|
139
|
+
headers: { Cookie: `${ADMIN.sessionCookie}=${cookieValue}` },
|
|
140
|
+
signal: AbortSignal.timeout(ADMIN.timeoutMs),
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
TS
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def membership
|
|
147
|
+
<<~TS.chomp
|
|
148
|
+
export interface MemberRow {
|
|
149
|
+
email?: unknown;
|
|
150
|
+
role?: unknown;
|
|
151
|
+
disabled?: unknown;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** True when `email` (any case) is a member holding one of the admin roles and not disabled. */
|
|
155
|
+
export function isActiveAdmin(members: MemberRow[], email: string): boolean {
|
|
156
|
+
const wanted = email.trim().toLowerCase();
|
|
157
|
+
return members.some(
|
|
158
|
+
(member) =>
|
|
159
|
+
typeof member.email === "string" &&
|
|
160
|
+
member.email.trim().toLowerCase() === wanted &&
|
|
161
|
+
(ADMIN.roles as readonly unknown[]).includes(member.role) &&
|
|
162
|
+
member.disabled !== true,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
TS
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def sessions
|
|
169
|
+
<<~TS.chomp
|
|
170
|
+
/** A signed-in admin and the membership list that vouched for them. */
|
|
171
|
+
export interface AdminSession {
|
|
172
|
+
email: string;
|
|
173
|
+
members: unknown[];
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function fetchAccountEmail(cookieValue: string): Promise<string | null> {
|
|
177
|
+
const res = await askHost(ADMIN.accountPath, cookieValue);
|
|
178
|
+
if (!res.ok) return null;
|
|
179
|
+
const data = await res.json();
|
|
180
|
+
return typeof data.email === "string" ? data.email : null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function fetchAdminSession(cookieValue: string): Promise<AdminSession | null> {
|
|
184
|
+
const [email, membersRes] = await Promise.all([
|
|
185
|
+
fetchAccountEmail(cookieValue),
|
|
186
|
+
askHost(ADMIN.membersPath, cookieValue),
|
|
187
|
+
]);
|
|
188
|
+
if (!email || !membersRes.ok) return null;
|
|
189
|
+
const members = await membersRes.json();
|
|
190
|
+
return Array.isArray(members) && isActiveAdmin(members, email) ? { email, members } : null;
|
|
191
|
+
}
|
|
192
|
+
TS
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def verdicts
|
|
196
|
+
<<~TS.chomp
|
|
197
|
+
const accountEmails = remember(fetchAccountEmail);
|
|
198
|
+
const adminSessions = remember(fetchAdminSession);
|
|
199
|
+
|
|
200
|
+
/** Drops every remembered verdict, for after this process changes a member. */
|
|
201
|
+
export function forgetAdminSessions(): void {
|
|
202
|
+
accountEmails.forget();
|
|
203
|
+
adminSessions.forget();
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** The signed-in person's email, or null when the cookie is absent or the host does not know it. */
|
|
207
|
+
export async function currentAccountEmail(cookieValue: string | undefined): Promise<string | null> {
|
|
208
|
+
return cookieValue ? accountEmails.get(cookieValue) : null;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** The signed-in person and the membership list when they hold an admin role, else null. */
|
|
212
|
+
export async function currentAdminSession(cookieValue: string | undefined): Promise<AdminSession | null> {
|
|
213
|
+
return cookieValue ? adminSessions.get(cookieValue) : null;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** The signed-in person's email when they may use the admin pages, else null. */
|
|
217
|
+
export async function currentAdminEmail(cookieValue: string | undefined): Promise<string | null> {
|
|
218
|
+
return (await currentAdminSession(cookieValue))?.email ?? null;
|
|
219
|
+
}
|
|
220
|
+
TS
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def handoff
|
|
224
|
+
<<~TS.chomp
|
|
225
|
+
/**
|
|
226
|
+
* Where the hand-off to the CMS sends a person: the CMS's own sign-in endpoint with a short-lived
|
|
227
|
+
* token from the host, or the login page when there is no session or the host refuses the token.
|
|
228
|
+
* `to` is a path inside the CMS to open once signed in; the CMS checks it.
|
|
229
|
+
*/
|
|
230
|
+
export async function ssoRedirect(cookieValue: string | undefined, to?: string | null): Promise<string> {
|
|
231
|
+
if (!cookieValue) return ADMIN.login;
|
|
232
|
+
const res = await askHost(ADMIN.ssoTokenPath, cookieValue);
|
|
233
|
+
if (!res.ok) return ADMIN.login;
|
|
234
|
+
const { token } = await res.json();
|
|
235
|
+
const params = new URLSearchParams({ token });
|
|
236
|
+
if (to) params.set("to", to);
|
|
237
|
+
return `${ADMIN.ssoTarget}?${params}`;
|
|
238
|
+
}
|
|
239
|
+
TS
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def routing
|
|
243
|
+
<<~'TS'.chomp
|
|
244
|
+
/**
|
|
245
|
+
* How specific a route pattern is: the characters that are not a wildcard or a `:name` parameter,
|
|
246
|
+
* so `/admin-login` outranks `/admin*` and `/admin.html` outranks `/:slug.html`.
|
|
247
|
+
*/
|
|
248
|
+
function specificity(pattern: string): number {
|
|
249
|
+
return pattern.replace(/:[A-Za-z_]\w*/g, "").replace(/\*/g, "").length;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* The route that describes a path: the most specific match. Between routes equally specific the
|
|
254
|
+
* `admin` one wins, so a tie never lets a visitor through; after that, the first listed.
|
|
255
|
+
*/
|
|
256
|
+
function routeFor(pathname: string): (typeof ROUTES)[number] | undefined {
|
|
257
|
+
let best: (typeof ROUTES)[number] | undefined;
|
|
258
|
+
for (const candidate of ROUTES) {
|
|
259
|
+
if (!matchesPath(candidate.path, pathname)) continue;
|
|
260
|
+
if (best === undefined) {
|
|
261
|
+
best = candidate;
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
const more = specificity(candidate.path) - specificity(best.path);
|
|
265
|
+
if (more > 0 || (more === 0 && candidate.auth === "admin" && best.auth !== "admin")) best = candidate;
|
|
266
|
+
}
|
|
267
|
+
return best;
|
|
268
|
+
}
|
|
269
|
+
TS
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def gate
|
|
273
|
+
<<~TS.chomp
|
|
274
|
+
export type AdminGate = { allow: true } | { allow: false; status: 401 } | { allow: false; redirect: string };
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Whether a request may go on. The most specific route that matches the path decides (see `routeFor`): an `admin`
|
|
278
|
+
* route, or any path under the draft-preview prefix, needs an admin session. A preview without
|
|
279
|
+
* one is refused with 401 and nothing else; any other admin path is sent to the login page.
|
|
280
|
+
*/
|
|
281
|
+
export async function adminGate(pathname: string, cookieValue: string | undefined): Promise<AdminGate> {
|
|
282
|
+
const previewing = pathname.startsWith(MIDDLEWARE.preview.prefix + "/");
|
|
283
|
+
if (routeFor(pathname)?.auth !== "admin" && !previewing) return { allow: true };
|
|
284
|
+
if (await currentAdminEmail(cookieValue)) return { allow: true };
|
|
285
|
+
return previewing ? { allow: false, status: 401 } : { allow: false, redirect: ADMIN.login };
|
|
286
|
+
}
|
|
287
|
+
TS
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
end
|