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.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/adapters/driven/memory/shared_elements.rb +121 -17
  3. data/lib/hecks/deploy/bluebook/deploy.bluebook +1630 -0
  4. data/lib/hecks/deploy/bluebook/deploy.hecksagon +65 -0
  5. data/lib/hecks/deploy/oidc.json +240 -0
  6. data/lib/hecks/doors/cli_runner.rb +13 -4
  7. data/lib/hecks/doors/launcher_options.rb +14 -2
  8. data/lib/hecks/hecks/adapters/bluebook_report.rb +91 -0
  9. data/lib/hecks/hecks/adapters/deploy_plans.rb +49 -0
  10. data/lib/hecks/hecks/adapters/deploy_toolchain.rb +379 -8
  11. data/lib/hecks/hecks/adapters/first_admin.rb +8 -1
  12. data/lib/hecks/hecks/adapters/git.rb +29 -0
  13. data/lib/hecks/hecks/adapters/in_process_operations.rb +6 -2
  14. data/lib/hecks/hecks/adapters/site_toolchain.rb +8 -8
  15. data/lib/hecks/hecks/custodian.bluebook +23 -2
  16. data/lib/hecks/hecks/hecks.hecksagon +66 -0
  17. data/lib/hecks/hecks/hecks.world +6 -1
  18. data/lib/hecks/hecks/site.world +4 -2
  19. data/lib/hecks/projections/deploy/box/hosting.rb +13 -3
  20. data/lib/hecks/projections/deploy/box/roll_recipe.rb +62 -0
  21. data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +10 -3
  22. data/lib/hecks/projections/deploy/box/templates/deploy-service.services-roll.part +2 -2
  23. data/lib/hecks/projections/deploy/box/templates/deploy-service.sh.tmpl +16 -4
  24. data/lib/hecks/projections/deploy/box/templates/deploy-service.taskdef-roll.part +6 -6
  25. data/lib/hecks/projections/deploy/box/templates/deploy-service.taskdef.part +1 -1
  26. data/lib/hecks/projections/deploy/box/templates/hosting.mk.tmpl +11 -5
  27. data/lib/hecks/projections/deploy/box/templates/restore-to-rds.sh.tmpl +8 -3
  28. data/lib/hecks/projections/deploy/box/templates/verify-copy.sh.tmpl +2 -2
  29. data/lib/hecks/projections/deploy/box.rb +26 -13
  30. data/lib/hecks/projections/site/admin.rb +140 -0
  31. data/lib/hecks/projections/site/admin_cms/templates.rb +388 -0
  32. data/lib/hecks/projections/site/admin_cms.rb +72 -0
  33. data/lib/hecks/projections/site/payload/fields.rb +234 -0
  34. data/lib/hecks/projections/site/payload/lifecycle.rb +191 -0
  35. data/lib/hecks/projections/site/payload/model.rb +128 -0
  36. data/lib/hecks/projections/site/payload/specs.rb +179 -0
  37. data/lib/hecks/projections/site/payload_driver.rb +93 -0
  38. data/lib/hecks/projections/site/root_rows.rb +59 -0
  39. data/lib/hecks/projections/site/site_admin.rb +292 -0
  40. data/lib/hecks/projections/site/site_host/templates.rb +165 -0
  41. data/lib/hecks/projections/site/site_host.rb +58 -0
  42. data/lib/hecks/projections/site/site_root.rb +179 -0
  43. data/lib/hecks/runtime/value.rb +7 -0
  44. data/lib/hecks/site/bluebook/site.bluebook +13 -2
  45. data/lib/hecks/tools/site_routes.rb +78 -8
  46. data/lib/hecks/version.rb +1 -1
  47. data/rust/host/HECKS_RELEASE +1 -1
  48. 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