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.
- checksums.yaml +4 -4
- data/lib/hecks/deploy/bluebook/deploy.bluebook +106 -0
- data/lib/hecks/deploy/bluebook/deploy.hecksagon +8 -0
- data/lib/hecks/deploy/oidc.json +16 -3
- data/lib/hecks/hecks/adapters/deploy_toolchain/clearing.rb +51 -0
- data/lib/hecks/hecks/adapters/deploy_toolchain.rb +2 -0
- data/lib/hecks/hecks/adapters/site_toolchain/role_prober.rb +78 -0
- data/lib/hecks/hecks/adapters/site_toolchain.rb +29 -4
- data/lib/hecks/hecks/adapters/world_clearing.rb +65 -0
- data/lib/hecks/hecks/hecks.hecksagon +8 -0
- data/lib/hecks/hecks/hecks.world +2 -1
- data/lib/hecks/projections/deploy/fargate/settings/tuning.rb +15 -0
- data/lib/hecks/projections/deploy/fargate/settings.rb +3 -1
- data/lib/hecks/projections/deploy/fargate/stack/readers.rb +1 -0
- data/lib/hecks/projections/deploy/templates/fargate/Makefile.tmpl +7 -4
- data/lib/hecks/projections/deploy/templates/lambda/template.yaml.tmpl +1 -1
- data/lib/hecks/projections/site/cms_editor/attributes.rb +75 -0
- data/lib/hecks/projections/site/cms_editor/checks.rb +64 -0
- data/lib/hecks/projections/site/cms_editor/media.rb +85 -0
- data/lib/hecks/projections/site/cms_editor/pretty.rb +40 -0
- data/lib/hecks/projections/site/cms_editor/schema.rb +84 -0
- data/lib/hecks/projections/site/cms_editor/setting.rb +87 -0
- data/lib/hecks/projections/site/cms_editor/templates/package.json.tmpl +19 -0
- data/lib/hecks/projections/site/cms_editor/templates/src/app.ts.tmpl +176 -0
- data/lib/hecks/projections/site/cms_editor/templates/src/auth/membership.ts.tmpl +79 -0
- data/lib/hecks/projections/site/cms_editor/templates/src/auth/session.ts.tmpl +36 -0
- data/lib/hecks/projections/site/cms_editor/templates/src/auth/sso.ts.tmpl +20 -0
- data/lib/hecks/projections/site/cms_editor/templates/src/commands.ts.tmpl +79 -0
- data/lib/hecks/projections/site/cms_editor/templates/src/config.ts.tmpl +23 -0
- data/lib/hecks/projections/site/cms_editor/templates/src/host.ts.tmpl +26 -0
- data/lib/hecks/projections/site/cms_editor/templates/src/media/handler.ts.tmpl +154 -0
- data/lib/hecks/projections/site/cms_editor/templates/src/media/multipart.ts.tmpl +53 -0
- data/lib/hecks/projections/site/cms_editor/templates/src/media/sniff.ts.tmpl +94 -0
- data/lib/hecks/projections/site/cms_editor/templates/src/media/storage.ts.tmpl +50 -0
- data/lib/hecks/projections/site/cms_editor/templates/src/schema.ts.tmpl +85 -0
- data/lib/hecks/projections/site/cms_editor/templates/src/server.ts.tmpl +65 -0
- data/lib/hecks/projections/site/cms_editor/templates/src/ui/body_model.js.tmpl +120 -0
- data/lib/hecks/projections/site/cms_editor/templates/src/ui/body_parse.js.tmpl +320 -0
- data/lib/hecks/projections/site/cms_editor/templates/src/ui/body_widget.js.tmpl +282 -0
- data/lib/hecks/projections/site/cms_editor/templates/src/ui/fields.ts.tmpl +80 -0
- data/lib/hecks/projections/site/cms_editor/templates/src/ui/html.ts.tmpl +102 -0
- data/lib/hecks/projections/site/cms_editor/templates/src/ui/input.ts.tmpl +75 -0
- data/lib/hecks/projections/site/cms_editor/templates/src/ui/media_picker.js.tmpl +109 -0
- data/lib/hecks/projections/site/cms_editor/templates/src/ui/outline.ts.tmpl +27 -0
- data/lib/hecks/projections/site/cms_editor/templates/src/ui/pages.ts.tmpl +123 -0
- data/lib/hecks/projections/site/cms_editor/templates/tsconfig.json.tmpl +16 -0
- data/lib/hecks/projections/site/cms_editor.rb +107 -0
- data/lib/hecks/projections/site/role_probe.rb +72 -0
- data/lib/hecks/site/bluebook/site.bluebook +28 -1
- data/lib/hecks/site/bluebook/site.ports.hecksagon +6 -0
- data/lib/hecks/tools/site_routes/cli.rb +1 -1
- data/lib/hecks/tools/site_routes/editor_files.rb +38 -0
- data/lib/hecks/tools/site_routes.rb +14 -7
- data/lib/hecks/version.rb +1 -1
- data/prebuilt/{verdicts-3230a251570aa03666708d16f92e646feab6a3f8cc2a3540c017d748e41d41a1.json → verdicts-d6d584ad43b5f3cebf88aafbbdbf024c38574acf18e06adffbc555f7786b1c2f.json} +1 -1
- data/rust/Cargo.toml +3 -1
- data/rust/codegen/src/bridging.rs +1 -1
- data/rust/codegen/src/domain_generator.rs +9 -9
- data/rust/codegen/src/main.rs +1 -1
- data/rust/codegen/src/mutations.rs +46 -1
- data/rust/codegen/src/naming.rs +30 -0
- data/rust/host/HECKS_RELEASE +1 -1
- data/rust/host/src/boot.rs +443 -0
- data/rust/host/src/lib.rs +1 -0
- data/rust/host/src/main.rs +4 -438
- data/rust/host/src/secrets.rs +15 -0
- data/rust/host/src/web/commerce.rs +11 -3
- data/rust/host/src/web/resend_webhook.rs +2 -1
- data/rust/src/kernel/expr.rs +7 -1
- data/rust/src/kernel/expression_operators/presence.rs +41 -3
- metadata +42 -5
- /data/prebuilt/{4e6c3cd1f578a38732ea5454dae52562d03d8e9886216077a5c34cbf589f3463.marshal → c28d6d9db14eea3e17161e9d9c262efee30b256cf97693a7cfd552bce24b2f7f.marshal} +0 -0
- /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 }
|
|
@@ -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>] [--
|
|
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
|
|
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
|
|
120
|
-
|
|
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