hecks 3.9.0 → 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.
Files changed (73) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/deploy/bluebook/deploy.bluebook +106 -0
  3. data/lib/hecks/deploy/bluebook/deploy.hecksagon +8 -0
  4. data/lib/hecks/deploy/oidc.json +16 -3
  5. data/lib/hecks/hecks/adapters/deploy_toolchain/clearing.rb +51 -0
  6. data/lib/hecks/hecks/adapters/deploy_toolchain.rb +2 -0
  7. data/lib/hecks/hecks/adapters/site_toolchain/role_prober.rb +78 -0
  8. data/lib/hecks/hecks/adapters/site_toolchain.rb +29 -4
  9. data/lib/hecks/hecks/adapters/world_clearing.rb +65 -0
  10. data/lib/hecks/hecks/hecks.hecksagon +8 -0
  11. data/lib/hecks/hecks/hecks.world +2 -1
  12. data/lib/hecks/projections/deploy/fargate/settings/tuning.rb +15 -0
  13. data/lib/hecks/projections/deploy/fargate/settings.rb +3 -1
  14. data/lib/hecks/projections/deploy/fargate/stack/readers.rb +1 -0
  15. data/lib/hecks/projections/deploy/templates/fargate/Makefile.tmpl +7 -4
  16. data/lib/hecks/projections/deploy/templates/lambda/template.yaml.tmpl +1 -1
  17. data/lib/hecks/projections/site/cms_editor/attributes.rb +75 -0
  18. data/lib/hecks/projections/site/cms_editor/checks.rb +64 -0
  19. data/lib/hecks/projections/site/cms_editor/media.rb +85 -0
  20. data/lib/hecks/projections/site/cms_editor/pretty.rb +40 -0
  21. data/lib/hecks/projections/site/cms_editor/schema.rb +84 -0
  22. data/lib/hecks/projections/site/cms_editor/setting.rb +87 -0
  23. data/lib/hecks/projections/site/cms_editor/templates/package.json.tmpl +19 -0
  24. data/lib/hecks/projections/site/cms_editor/templates/src/app.ts.tmpl +176 -0
  25. data/lib/hecks/projections/site/cms_editor/templates/src/auth/membership.ts.tmpl +79 -0
  26. data/lib/hecks/projections/site/cms_editor/templates/src/auth/session.ts.tmpl +36 -0
  27. data/lib/hecks/projections/site/cms_editor/templates/src/auth/sso.ts.tmpl +20 -0
  28. data/lib/hecks/projections/site/cms_editor/templates/src/commands.ts.tmpl +79 -0
  29. data/lib/hecks/projections/site/cms_editor/templates/src/config.ts.tmpl +23 -0
  30. data/lib/hecks/projections/site/cms_editor/templates/src/host.ts.tmpl +26 -0
  31. data/lib/hecks/projections/site/cms_editor/templates/src/media/handler.ts.tmpl +154 -0
  32. data/lib/hecks/projections/site/cms_editor/templates/src/media/multipart.ts.tmpl +53 -0
  33. data/lib/hecks/projections/site/cms_editor/templates/src/media/sniff.ts.tmpl +94 -0
  34. data/lib/hecks/projections/site/cms_editor/templates/src/media/storage.ts.tmpl +50 -0
  35. data/lib/hecks/projections/site/cms_editor/templates/src/schema.ts.tmpl +85 -0
  36. data/lib/hecks/projections/site/cms_editor/templates/src/server.ts.tmpl +65 -0
  37. data/lib/hecks/projections/site/cms_editor/templates/src/ui/body_model.js.tmpl +120 -0
  38. data/lib/hecks/projections/site/cms_editor/templates/src/ui/body_parse.js.tmpl +320 -0
  39. data/lib/hecks/projections/site/cms_editor/templates/src/ui/body_widget.js.tmpl +282 -0
  40. data/lib/hecks/projections/site/cms_editor/templates/src/ui/fields.ts.tmpl +80 -0
  41. data/lib/hecks/projections/site/cms_editor/templates/src/ui/html.ts.tmpl +102 -0
  42. data/lib/hecks/projections/site/cms_editor/templates/src/ui/input.ts.tmpl +75 -0
  43. data/lib/hecks/projections/site/cms_editor/templates/src/ui/media_picker.js.tmpl +109 -0
  44. data/lib/hecks/projections/site/cms_editor/templates/src/ui/outline.ts.tmpl +27 -0
  45. data/lib/hecks/projections/site/cms_editor/templates/src/ui/pages.ts.tmpl +123 -0
  46. data/lib/hecks/projections/site/cms_editor/templates/tsconfig.json.tmpl +16 -0
  47. data/lib/hecks/projections/site/cms_editor.rb +107 -0
  48. data/lib/hecks/projections/site/role_probe.rb +72 -0
  49. data/lib/hecks/site/bluebook/site.bluebook +28 -1
  50. data/lib/hecks/site/bluebook/site.ports.hecksagon +6 -0
  51. data/lib/hecks/tools/site_routes/cli.rb +1 -1
  52. data/lib/hecks/tools/site_routes/editor_files.rb +38 -0
  53. data/lib/hecks/tools/site_routes.rb +14 -7
  54. data/lib/hecks/version.rb +1 -1
  55. data/prebuilt/{verdicts-3230a251570aa03666708d16f92e646feab6a3f8cc2a3540c017d748e41d41a1.json → verdicts-d6d584ad43b5f3cebf88aafbbdbf024c38574acf18e06adffbc555f7786b1c2f.json} +1 -1
  56. data/rust/Cargo.toml +3 -1
  57. data/rust/codegen/src/bridging.rs +1 -1
  58. data/rust/codegen/src/domain_generator.rs +9 -9
  59. data/rust/codegen/src/main.rs +1 -1
  60. data/rust/codegen/src/mutations.rs +46 -1
  61. data/rust/codegen/src/naming.rs +30 -0
  62. data/rust/host/HECKS_RELEASE +1 -1
  63. data/rust/host/src/boot.rs +443 -0
  64. data/rust/host/src/lib.rs +1 -0
  65. data/rust/host/src/main.rs +4 -438
  66. data/rust/host/src/secrets.rs +15 -0
  67. data/rust/host/src/web/commerce.rs +11 -3
  68. data/rust/host/src/web/resend_webhook.rs +2 -1
  69. data/rust/src/kernel/expr.rs +7 -1
  70. data/rust/src/kernel/expression_operators/presence.rs +41 -3
  71. metadata +42 -5
  72. /data/prebuilt/{4e6c3cd1f578a38732ea5454dae52562d03d8e9886216077a5c34cbf589f3463.marshal → c28d6d9db14eea3e17161e9d9c262efee30b256cf97693a7cfd552bce24b2f7f.marshal} +0 -0
  73. /data/prebuilt/{911cdcc56b5a094ed26583a4c76401dba6455b1f434722ed22b850bc1b1bdd28.marshal → cb375cba61ca0dbe8ddb9739c306510fe05e5f4b7a9b6955caff8224c63ea0e0.marshal} +0 -0
@@ -0,0 +1,27 @@
1
+ __BANNER__
2
+
3
+ // A value shown read-only, whatever its shape: a scalar as text, a list as a list, a value object
4
+ // as its named parts. A rich-text body is not shown this way; pages.ts renders it with bodyToHtml.
5
+ import { esc } from "./html.ts";
6
+
7
+ const blank = (value: unknown): boolean => value === null || value === undefined || value === "";
8
+
9
+ /** `value` as a nested read-only outline. */
10
+ export function outline(value: unknown): string {
11
+ if (blank(value)) return `<em>empty</em>`;
12
+ if (Array.isArray(value)) return value.length === 0 ? `<em>none</em>` : `<ul>${value.map((item) => `<li>${outline(item)}</li>`).join("")}</ul>`;
13
+ if (typeof value !== "object") return esc(value);
14
+ const entries = Object.entries(value as Record<string, unknown>);
15
+ if (entries.length === 1 && entries[0][0] === "value") return outline(entries[0][1]);
16
+ const rows = entries.map(([key, part]) => `<div><strong>${esc(key)}</strong>: ${outline(part)}</div>`);
17
+ return `<div class="outline">${rows.join("")}</div>`;
18
+ }
19
+
20
+ /** A one-line summary of a value, for a table cell: single-part objects show the part, lists their size. */
21
+ export function summary(value: unknown): string {
22
+ if (blank(value)) return "";
23
+ if (Array.isArray(value)) return `${value.length} ${value.length === 1 ? "item" : "items"}`;
24
+ if (typeof value !== "object") return esc(value);
25
+ const parts = Object.values(value as Record<string, unknown>).filter((part) => !blank(part) && !Array.isArray(part));
26
+ return esc(parts.join(", "));
27
+ }
@@ -0,0 +1,123 @@
1
+ __BANNER__
2
+
3
+ // The pages: a nav of aggregates, a list per aggregate (from a query when it has one), a detail page
4
+ // per instance, and a form per command generated from the command's attributes. Lifecycle state is a
5
+ // badge, and a lifecycle move is offered only from the states it applies in.
6
+ import type { Aggregate, Attr, Command, Query } from "../schema.ts";
7
+ import { bodyToHtml } from "./body_model.js";
8
+ import { fields } from "./fields.ts";
9
+ import { badge, esc, href, page, refusal } from "./html.ts";
10
+ import { outline, summary } from "./outline.ts";
11
+ import { SCHEMA } from "../schema.ts";
12
+
13
+ type State = Record<string, unknown>;
14
+ type Row = [string, State];
15
+
16
+ /** What a submitted command left behind: the refusal to show, and the values to put back. */
17
+ export interface Submitted {
18
+ command: string;
19
+ message: string | null;
20
+ values: State;
21
+ }
22
+
23
+ const label = (name: string): string => name.replace(/_/g, " ");
24
+
25
+ /** The home page: every aggregate the domain declares. */
26
+ export function home(): string {
27
+ const items = SCHEMA.aggregates.map(
28
+ (agg) => `<li><a href="${esc(href(agg.name))}">${esc(agg.name)}</a>${agg.description ? ` <span class="note">${esc(agg.description)}</span>` : ""}</li>`,
29
+ );
30
+ return page("Home", `<h1>${esc(SCHEMA.domain)}</h1>\n<ul>\n${items.join("\n")}\n</ul>`);
31
+ }
32
+
33
+ /** The page for a path that names nothing. */
34
+ export function notFound(what: string): string {
35
+ return page("Not found", `<h1>Not found</h1>\n<p>${esc(what)}</p>`);
36
+ }
37
+
38
+ /** A form for one command; `to` adds the id input a command acting on another aggregate needs. */
39
+ export function commandForm(agg: Aggregate, command: Command, action: string, shown: Submitted | null, state: State = {}, to = false): string {
40
+ const own = shown && shown.command === command.name ? shown : null;
41
+ const note = command.goal ? `<p class="note">${esc(command.goal)}</p>` : "";
42
+ const target = to ? `<label>${esc(command.on ?? "")} id<input type="text" name="__to" value="${esc(own?.values.__to ?? "")}"></label>` : "";
43
+ return `<form method="post" action="${esc(action)}">
44
+ ${refusal(own?.message ?? null)}${note}
45
+ ${target}
46
+ ${fields(agg, command.attributes, own ? own.values : state)}
47
+ <button type="submit">${esc(label(command.name))}</button>
48
+ </form>`;
49
+ }
50
+
51
+ /** The page of one creating command (or a command that acts on another aggregate's instance). */
52
+ export function commandPage(agg: Aggregate, command: Command, action: string, shown: Submitted | null): string {
53
+ return page(command.name, `<h1>${esc(label(command.name))} <small>${esc(agg.name)}</small></h1>\n${commandForm(agg, command, action, shown, {}, command.on !== null && command.on !== agg.name)}`);
54
+ }
55
+
56
+ const columns = (agg: Aggregate) => agg.attributes.filter((attr) => !attr.list && attr.widget !== "body").slice(0, 4);
57
+
58
+ function table(agg: Aggregate, rows: Row[]): string {
59
+ const cols = columns(agg);
60
+ const head = ["id", ...cols.map((attr) => label(attr.name)), ...(agg.lifecycle ? ["status"] : [])];
61
+ const lines = rows.map(([id, state]) => {
62
+ const cells = cols.map((attr) => `<td>${summary(state[attr.name])}</td>`);
63
+ const status = agg.lifecycle ? `<td>${badge(state[agg.lifecycle.field])}</td>` : "";
64
+ return `<tr><td><a href="${esc(href(agg.name, "id", id))}">${esc(id)}</a></td>${cells.join("")}${status}</tr>`;
65
+ });
66
+ const empty = rows.length === 0 ? `<tr><td colspan="${head.length}"><em>nothing here yet</em></td></tr>` : lines.join("\n");
67
+ return `<table>\n<tr>${head.map((name) => `<th>${esc(name)}</th>`).join("")}</tr>\n${empty}\n</table>`;
68
+ }
69
+
70
+ function queryLinks(agg: Aggregate, current: Query | null): string {
71
+ const links = agg.queries.map((query) => `<a href="${esc(`${href(agg.name)}?query=${encodeURIComponent(query.name)}`)}">${esc(label(query.name))}</a>`);
72
+ const all = `<a href="${esc(href(agg.name))}">all</a>`;
73
+ return `<p>${[all, ...links].join(" | ")}${current ? ` <span class="note">showing ${esc(current.name)}</span>` : ""}</p>`;
74
+ }
75
+
76
+ function queryForm(agg: Aggregate, query: Query | null): string {
77
+ if (!query || query.attributes.length === 0) return "";
78
+ const action = esc(href(agg.name));
79
+ return `<form method="get" action="${action}"><input type="hidden" name="query" value="${esc(query.name)}">
80
+ ${fields(agg, query.attributes)}
81
+ <button type="submit">Ask</button></form>`;
82
+ }
83
+
84
+ /** An aggregate's list: the rows of the query asked, or of everything it holds. */
85
+ export function listPage(agg: Aggregate, rows: Row[], query: Query | null, error: string | null): string {
86
+ const creating = agg.commands.filter((command) => command.creates);
87
+ const actions = creating.map((command) => `<a href="${esc(href(agg.name, "new", command.name))}">${esc(label(command.name))}</a>`).join(" | ");
88
+ const elsewhere = agg.commands.filter((command) => !command.creates && command.on !== agg.name);
89
+ const others = elsewhere.map((command) => `<a href="${esc(href(agg.name, "run", command.name))}">${esc(label(command.name))}</a>`).join(" | ");
90
+ const body = `<h1>${esc(agg.name)}</h1>\n${agg.description ? `<p class="note">${esc(agg.description)}</p>` : ""}
91
+ ${actions ? `<p>New: ${actions}</p>` : ""}${others ? `<p>Other: ${others}</p>` : ""}
92
+ ${queryLinks(agg, query)}${queryForm(agg, query)}${refusal(error)}
93
+ ${table(agg, rows)}`;
94
+ return page(agg.name, body);
95
+ }
96
+
97
+ function available(agg: Aggregate, command: Command, state: State): boolean {
98
+ const move = agg.lifecycle?.transitions.find((transition) => transition.verb === command.name);
99
+ return !move || !agg.lifecycle || move.from.includes(String(state[agg.lifecycle.field]));
100
+ }
101
+
102
+ /** An attribute's value read-only: a rich-text body as escaped HTML, anything else as an outline. */
103
+ function shown(attr: Attr, value: unknown): string {
104
+ if (attr.widget !== "body") return outline(value);
105
+ return value && typeof value === "object" ? `<div class="body">${bodyToHtml(value)}</div>` : `<em>empty</em>`;
106
+ }
107
+
108
+ function state(agg: Aggregate, values: State): string {
109
+ const rows = agg.attributes.map((attr) => `<tr><th>${esc(label(attr.name))}</th><td>${shown(attr, values[attr.name])}</td></tr>`);
110
+ return `<table>\n${rows.join("\n")}\n</table>`;
111
+ }
112
+
113
+ /** One instance: its status, its values read-only, and a form for each command that applies to it. */
114
+ export function detailPage(agg: Aggregate, id: string, values: State, shown: Submitted | null): string {
115
+ const acting = agg.commands.filter((command) => command.on === agg.name && available(agg, command, values));
116
+ const forms = acting.map((command) => {
117
+ const open = command.attributes.length > 0 ? ` ${shown && shown.command === command.name ? "open" : ""}` : "";
118
+ const body = commandForm(agg, command, href(agg.name, "id", id, command.name), shown, values);
119
+ return command.attributes.length > 0 ? `<details${open}><summary>${esc(label(command.name))}</summary>\n${body}\n</details>` : body;
120
+ });
121
+ const status = agg.lifecycle ? badge(values[agg.lifecycle.field]) : "";
122
+ return page(id, `<h1>${esc(id)} ${status}</h1>\n<p><a href="${esc(href(agg.name))}">${esc(agg.name)}</a></p>\n${state(agg, values)}\n<h2>Change</h2>\n${forms.join("\n")}`);
123
+ }
@@ -0,0 +1,16 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "strict": true,
7
+ "noEmit": true,
8
+ "allowImportingTsExtensions": true,
9
+ "allowJs": true,
10
+ "erasableSyntaxOnly": true,
11
+ "verbatimModuleSyntax": true,
12
+ "skipLibCheck": true,
13
+ "types": ["node"]
14
+ },
15
+ "include": ["src"]
16
+ }
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "../../projector"
5
+ require_relative "cms_editor/setting"
6
+ require_relative "cms_editor/schema"
7
+ require_relative "cms_editor/pretty"
8
+
9
+ module Hecks
10
+ module Projections
11
+ module Site
12
+ # A small Node/TypeScript editor for a domain's content, as the files of a package: a server
13
+ # that signs editors in through the site's own hand-off and shows a page per aggregate, a list
14
+ # from its queries, a detail page per instance and a form per command.
15
+ #
16
+ # It is generic by aggregate. The pages and forms are not written per aggregate; they read
17
+ # `src/schema.ts`, which is the domain chapter's aggregates, attributes, value objects,
18
+ # lifecycles, commands and queries as one typed constant. The domain holds no editor data: the
19
+ # editor reads and dispatches through the host like any other client, and the output is a pure
20
+ # function of the chapter and the `Editor` row, so `--check` can hold it current.
21
+ module CmsEditor
22
+ extend Projector::Target
23
+
24
+ projects_as :site_cms_editor, emits: :files
25
+
26
+ # Where the template files are.
27
+ TEMPLATES = File.join(__dir__, "cms_editor", "templates")
28
+
29
+ # The files, by path relative to the directory they are written to.
30
+ FILES = %w[
31
+ package.json tsconfig.json src/config.ts src/schema.ts src/host.ts src/commands.ts src/app.ts src/server.ts
32
+ src/auth/membership.ts src/auth/session.ts src/auth/sso.ts
33
+ src/ui/html.ts src/ui/outline.ts src/ui/fields.ts src/ui/input.ts src/ui/pages.ts
34
+ src/ui/body_model.js src/ui/body_parse.js src/ui/body_widget.js
35
+ ].freeze
36
+
37
+ # The files only an editor of a chapter with a picture aggregate has: the upload, the
38
+ # storage port with its local-disk adapter, and the picker the rich-text widget opens.
39
+ MEDIA_FILES = %w[
40
+ src/media/sniff.ts src/media/multipart.ts src/media/storage.ts src/media/handler.ts src/ui/media_picker.js
41
+ ].freeze
42
+
43
+ # The browser modules the rich-text widget loads, served to a signed-in editor.
44
+ ASSETS = %w[body_widget.js body_model.js body_parse.js].freeze
45
+
46
+ # What `app.ts` holds for a picture upload; each is left out when the chapter has none.
47
+ MEDIA_LINES = {
48
+ "__MEDIA_IMPORT__" => ["import { mediaHandler } from \"./media/handler.ts\";",
49
+ "import type { MediaStorage } from \"./media/storage.ts\";"].join("\n"),
50
+ "__MEDIA_OPTION__" => [" /** Where picture bytes are kept: the local disk unless given (see media/storage.ts). */",
51
+ " storage?: MediaStorage;"].join("\n"),
52
+ "__MEDIA_SETUP__" => " const media = mediaHandler(options.storage);",
53
+ "__MEDIA_ROUTE__" => " if (segments[0] === \"media\") return media(client, request, segments.slice(1));"
54
+ }.freeze
55
+
56
+ module_function
57
+
58
+ # Renders the editor's files for a project that declares an `Editor` row.
59
+ #
60
+ # @param bluebook [Bluebook::Chapter] the chapter that declares the route table and the row
61
+ # @param options [Hash{Symbol => Object}] `:domain_chapter` the chapter to edit; `:table`
62
+ # the checked route table, when the login page is to be checked against it
63
+ # @return [Hash{String => String}] each file's path relative to the editor's directory to
64
+ # its text; empty when the project declares no `Editor` row
65
+ # @raise [Table::Invalid] when the row is refused
66
+ # @raise [ArgumentError] when the chapter has no aggregate to edit
67
+ def call(bluebook:, options: {})
68
+ setting = Setting.read(bluebook, table: options[:table])
69
+ return {} unless setting
70
+
71
+ schema = Schema.read(options.fetch(:domain_chapter), skip: setting.skip)
72
+ media = schema.key?("media")
73
+ tokens = setting.tokens.merge("__SCHEMA__" => Pretty.generate(schema), **media_tokens(media))
74
+ [*FILES, *(MEDIA_FILES if media)].to_h { |path| [path, fill(File.read(File.join(TEMPLATES, "#{path}.tmpl")), tokens)] }
75
+ end
76
+
77
+ # @param media [Boolean] whether the chapter has a picture aggregate
78
+ # @return [Hash{String => String}] each media placeholder to its text, empty when none
79
+ def media_tokens(media)
80
+ assets = RoutesTs.literal(media ? [*ASSETS, "media_picker.js"] : ASSETS)
81
+ lines = media ? MEDIA_LINES : MEDIA_LINES.transform_values { "" }
82
+ { "__ASSETS__" => assets, **lines }
83
+ end
84
+
85
+ # @param text [String] a template with `__NAME__` placeholders
86
+ # @param tokens [Hash{String => String}] each placeholder to its text
87
+ # @return [String] `text` with each placeholder replaced in one pass, so a value that looks
88
+ # like a placeholder is left as it is; a placeholder alone on its line whose text is empty
89
+ # takes the line with it
90
+ def fill(text, tokens)
91
+ text.gsub(/^(__[A-Z_]+__)\n|__[A-Z_]+__/) do
92
+ line = Regexp.last_match(1)
93
+ token = line || Regexp.last_match(0)
94
+ replacement(tokens.fetch(token, token), alone: !line.nil?)
95
+ end
96
+ end
97
+
98
+ # @return [String] `value`, with its line's newline when it stood alone and is not empty
99
+ def replacement(value, alone:)
100
+ return value unless alone
101
+
102
+ value.empty? ? "" : "#{value}\n"
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hecks
4
+ module Projections
5
+ module Site
6
+ # The commands of a project that declare a role, and the verdict on a host's answer to each
7
+ # sent with well-formed arguments but no role and no actor. It sends nothing: the caller
8
+ # dispatches each command and hands the answer back.
9
+ #
10
+ # The arguments are synthesized from the declaration, because a host checks that arguments are
11
+ # present and well-typed before it checks who is calling. A host that enforces roles refuses
12
+ # such a caller as `Unauthorized`. Any other answer (an accepted command, or a refusal for
13
+ # another reason) means the role did not stop it.
14
+ class RoleProbe
15
+ # One command that declares a role.
16
+ #
17
+ # @!attribute [r] verb
18
+ # @return [String] the command as a host is asked to run it, `Chapter::Aggregate.Command`
19
+ # @!attribute [r] role
20
+ # @return [String] the role the command declares
21
+ # @!attribute [r] arguments
22
+ # @return [Hash] one synthesized value per declared attribute
23
+ Check = Struct.new(:verb, :role, :arguments, keyword_init: true)
24
+
25
+ # The refusal kind a host enforcing roles answers a caller of the wrong role with.
26
+ REFUSAL = "Unauthorized"
27
+
28
+ # The refusal kinds of an argument the host would not build, before it asks who is calling.
29
+ ARGUMENT_REFUSALS = %w[AbsentArgument TypeMismatch InvariantViolation].freeze
30
+
31
+ # @param registry [Runtime::Registry] the registry a project booted into
32
+ # @return [Array<Check>] every command that declares a role, in the order declared
33
+ def self.checks(registry)
34
+ registry.bluebooks.values.flat_map do |chapter|
35
+ chapter.aggregates.flat_map do |aggregate|
36
+ aggregate.commands.select(&:role).map { |command| check_for(chapter, aggregate, command) }
37
+ end
38
+ end
39
+ end
40
+
41
+ # @return [Check] the check for one command that declares a role
42
+ def self.check_for(chapter, aggregate, command)
43
+ Check.new(verb: "#{chapter.name}::#{aggregate.hecks_name}.#{command.hecks_name}", role: command.role,
44
+ arguments: Bluebook::Synthesizer.args_for(chapter, aggregate, command))
45
+ end
46
+
47
+ # Whether the host refused the synthesized arguments themselves, so the role gate was never
48
+ # reached and the check proves nothing either way.
49
+ #
50
+ # @param answer [Hash] the host's parsed JSON answer
51
+ # @return [Boolean] true when every refusal is an argument refusal
52
+ def self.unchecked?(answer)
53
+ kinds = Array(answer["refusals"]).map { |refusal| refusal["kind"] }
54
+ kinds.any? && (kinds - ARGUMENT_REFUSALS).empty?
55
+ end
56
+
57
+ # Judges a host's answer to one check.
58
+ #
59
+ # @param check [Check] the command that was dispatched with no role and no actor
60
+ # @param answer [Hash] the host's parsed JSON answer
61
+ # @return [String, nil] why the answer is wrong, or nil when the host refused the caller
62
+ def self.verdict(check, answer)
63
+ refusals = Array(answer["refusals"])
64
+ return nil if refusals.any? { |refusal| refusal["kind"] == REFUSAL }
65
+ return "was refused, but not as #{REFUSAL}: #{refusals.map { |refusal| refusal["kind"] }.join(", ")}" if refusals.any?
66
+
67
+ "was not refused for its role (#{check.role}); is the host running with HECKS_ROLE_ENFORCEMENT=enforce?"
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -101,6 +101,7 @@ Hecks.bluebook "Site" do
101
101
  attribute :template, Location, optional: true
102
102
  attribute :cms, Location, optional: true
103
103
  attribute :root, Location, optional: true
104
+ attribute :editor, Location, optional: true
104
105
  attribute :extension, Extension, optional: true
105
106
  attribute :check, Switch, optional: true
106
107
  attribute :live, Location, optional: true
@@ -170,7 +171,7 @@ Hecks.bluebook "Site" do
170
171
  transition "Fault" => "faulted", from: "requested"
171
172
  end
172
173
 
173
- # `hecks site site_projection.project_site <project> [out=<dir>] [template=<file>] [cms=<dir>] [root=<dir>] [extension=ts|mts] [--check]`.
174
+ # `hecks site site_projection.project_site <project> [out=<dir>] [template=<file>] [cms=<dir>] [root=<dir>] [editor=<dir>] [extension=ts|mts] [--check]`.
174
175
  # With `check` nothing is written: a routes.ts that differs from the table is faulted, naming the
175
176
  # file, so a CI job fails on drift.
176
177
  command "ProjectSite" do
@@ -182,6 +183,7 @@ Hecks.bluebook "Site" do
182
183
  attribute :template, Location, optional: true
183
184
  attribute :cms, Location, optional: true
184
185
  attribute :root, Location, optional: true
186
+ attribute :editor, Location, optional: true
185
187
  attribute :extension, Extension, optional: true
186
188
  attribute :check, Switch, optional: true
187
189
 
@@ -191,6 +193,7 @@ Hecks.bluebook "Site" do
191
193
  sets :template
192
194
  sets :cms
193
195
  sets :root
196
+ sets :editor
194
197
  sets :extension
195
198
  sets :check
196
199
 
@@ -250,6 +253,25 @@ Hecks.bluebook "Site" do
250
253
  emits "SiteProbeRequested"
251
254
  end
252
255
 
256
+ # `hecks site site_projection.check_roles <project> url=<http://127.0.0.1:port>`. Dispatches every
257
+ # command the project declares a `role` for to a running host with no role and no actor, and
258
+ # checks the host refuses each as `Unauthorized`. Only a host on this machine is asked, and it
259
+ # must be running with `HECKS_ROLE_ENFORCEMENT=enforce`: a host that is not would run the
260
+ # commands. It prints a line per command and exits 1 when any is not refused for its role.
261
+ command "CheckRoles" do
262
+ goal "Check a host on this machine refuses every role-declaring command from a caller with no role"
263
+
264
+ attribute :domain, DomainPath
265
+ attribute :run, RunKey
266
+ attribute :url, SiteUrl
267
+
268
+ sets :domain
269
+ sets :run
270
+ sets :url
271
+
272
+ emits "RolesCheckRequested"
273
+ end
274
+
253
275
  command "Complete" do
254
276
  goal "Record that routes.ts was written, or found current, or the live edge matched, and what it said"
255
277
 
@@ -301,6 +323,11 @@ Hecks.bluebook "Site" do
301
323
  trigger SiteProjection::SiteToolchain::Probe, with: { run: :run }
302
324
  end
303
325
 
326
+ policy "RolesWhenRequested" do
327
+ on "SiteProjection.RolesCheckRequested"
328
+ trigger SiteProjection::SiteToolchain::Roles, with: { run: :run }
329
+ end
330
+
304
331
  policy "RecordTheProjection" do
305
332
  on "SiteProjection.SiteAnswered"
306
333
  trigger SiteProjection::Complete, with: { output: :output }
@@ -23,5 +23,11 @@ Hecks.hecksagon "Site" do
23
23
  answers "SiteAnswered"
24
24
  refuses "SiteRefused"
25
25
  end
26
+
27
+ asks "Roles", to: SiteProjection do
28
+ attribute :run, RunKey
29
+ answers "SiteAnswered"
30
+ refuses "SiteRefused"
31
+ end
26
32
  end
27
33
  end
@@ -13,7 +13,7 @@ module Hecks
13
13
  # The flags that take a value, by the keyword of `projection` each one sets.
14
14
  VALUE_FLAGS = {
15
15
  out: "--out=DIR", template: "--template=FILE", cms: "--cms=DIR", root_dir: "--root=DIR",
16
- extension: "--extension=EXT"
16
+ editor: "--editor=DIR", extension: "--extension=EXT"
17
17
  }.freeze
18
18
 
19
19
  # @param argv [Array<String>] the command line, consumed down to the project directory
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../tools"
4
+
5
+ module Hecks
6
+ module Tools
7
+ module SiteRoutes
8
+ # The editor's files, written under the `--editor` directory when the project declares an
9
+ # `Editor` row.
10
+ module EditorFiles
11
+ # @param editor [String] the directory the files are written under
12
+ # @param project [String] the project directory; the row's `domain` is relative to it
13
+ # @param site [Site] the project's route-table chapter, checked table and registry
14
+ # @return [Hash{String => String}] each file's absolute path to its text
15
+ # @raise [SystemExit] when `--editor` is named and the project declares no `Editor` row, or
16
+ # the row names a domain or chapter that is not there
17
+ def editor_files(editor, project, site)
18
+ setting = Projections::Site::CmsEditor::Setting.read(site.chapter, table: site.table)
19
+ abort "project_site: --editor names #{editor}, but the project declares no Editor row" if setting.nil?
20
+
21
+ options = { domain_chapter: domain_chapter(project, setting), table: site.table }
22
+ files = Projector.call(:site_cms_editor, bluebook: site.chapter, options: options)
23
+ files.to_h { |name, text| [File.join(File.expand_path(editor), name), text] }
24
+ rescue ArgumentError, Projections::Site::Table::Invalid => e
25
+ abort "project_site: #{e.message}"
26
+ end
27
+
28
+ # @return [Bluebook::Chapter] the chapter the `Editor` row names, from its domain directory
29
+ def domain_chapter(project, setting)
30
+ domain_root = File.join(project, setting.domain)
31
+ chapter = registry_for(domain_root).bluebooks.values.find { |candidate| candidate.name == setting.chapter }
32
+ abort "project_site: #{domain_root} declares no chapter #{setting.chapter}" unless chapter
33
+ chapter
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -13,9 +13,11 @@ require_relative "../projections/site/admin_cms"
13
13
  require_relative "../projections/site/site_root"
14
14
  require_relative "../projections/site/site_host"
15
15
  require_relative "../projections/site/payload_driver"
16
+ require_relative "../projections/site/cms_editor"
16
17
  require_relative "site_routes/cli"
17
18
  require_relative "site_routes/templates"
18
19
  require_relative "site_routes/root_files"
20
+ require_relative "site_routes/editor_files"
19
21
 
20
22
  module Hecks
21
23
  module Tools
@@ -39,13 +41,14 @@ module Hecks
39
41
  EXTENSIONS = %w[ts mts].freeze
40
42
 
41
43
  USAGE = "usage: hecks site site_projection.project_site [<project>] [--out=<dir>] " \
42
- "[--template=<file>] [--cms=<dir>] [--root=<dir>] [--extension=#{EXTENSIONS.join("|")}] [--check]".freeze
44
+ "[--template=<file>] [--cms=<dir>] [--root=<dir>] [--editor=<dir>] " \
45
+ "[--extension=#{EXTENSIONS.join("|")}] [--check]".freeze
43
46
 
44
47
  # The directory `ports/` and `adapters/` live under, loaded into every project's registry.
45
48
  LIB_HECKS = File.expand_path("..", __dir__)
46
49
 
47
50
  # The keywords `projection` takes, each nil until given.
48
- Settings = Struct.new(:out, :template, :extension, :cms, :root_dir, keyword_init: true)
51
+ Settings = Struct.new(:out, :template, :extension, :cms, :root_dir, :editor, keyword_init: true)
49
52
 
50
53
  # A project's route-table chapter with its checked table and the registry it booted into.
51
54
  Site = Struct.new(:chapter, :table, :registry)
@@ -53,6 +56,7 @@ module Hecks
53
56
  extend Cli
54
57
  extend Templates
55
58
  extend RootFiles
59
+ extend EditorFiles
56
60
 
57
61
  module_function
58
62
 
@@ -85,6 +89,7 @@ module Hecks
85
89
  # `extension:` `ts` (the default) or `mts`, the module's file extension
86
90
  # `cms:` the directory for the content system's half of the admin sign-in
87
91
  # `root_dir:` the directory for the project's root files
92
+ # `editor:` the directory for the content editor
88
93
  # @return [Hash{String => String}] each file's absolute path to the text it should hold
89
94
  # @raise [SystemExit] when the project declares no route table, the table is refused, or a
90
95
  # path or the extension is refused
@@ -106,7 +111,7 @@ module Hecks
106
111
  written = route_files(dir, site, settings.extension)
107
112
  admin = Projections::Site::Admin.read(site.chapter, table: site.table)
108
113
  written.merge!(admin_files(dir, site.chapter, admin, settings.extension))
109
- written.merge!(optional_files(settings, site.chapter, admin))
114
+ written.merge!(optional_files(settings, site, admin, root))
110
115
  written.merge(template_files(root, settings.out, site, template: settings.template))
111
116
  end
112
117
 
@@ -116,11 +121,13 @@ module Hecks
116
121
  renamed(dir, files, extension)
117
122
  end
118
123
 
119
- # The content system's files and the project's root files, each when its flag was given.
120
- def optional_files(settings, chapter, admin)
124
+ # The content system's files, the project's root files and the editor, each when its flag was
125
+ # given.
126
+ def optional_files(settings, site, admin, project)
121
127
  files = {}
122
- files.merge!(cms_files(settings.cms, chapter, admin)) if settings.cms
123
- files.merge!(root_files(settings.root_dir, chapter)) if settings.root_dir
128
+ files.merge!(cms_files(settings.cms, site.chapter, admin)) if settings.cms
129
+ files.merge!(root_files(settings.root_dir, site.chapter)) if settings.root_dir
130
+ files.merge!(editor_files(settings.editor, project, site)) if settings.editor
124
131
  files
125
132
  end
126
133
 
data/lib/hecks/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  module Hecks
2
2
  # Read directly by the gemspec, so this file must never require the
3
3
  # rest of the gem (its dependencies aren't resolved yet at that point).
4
- VERSION = "3.9.0".freeze
4
+ VERSION = "3.10.0".freeze
5
5
  end