morph 0.1.0
Asynchronous bridge between GUI thread and business models
Loading...
Searching...
No Matches
service.hpp
Go to the documentation of this file.
1// SPDX-License-Identifier: Apache-2.0
2
3#pragma once
4#include <algorithm>
5#include <compare>
6#include <cstddef>
7#include <expected>
8#include <format>
9#include <functional>
10#include <map>
11#include <memory>
12#include <optional>
13#include <set>
14#include <stdexcept>
15#include <string>
16#include <string_view>
17#include <utility>
18#include <vector>
19
20#include "../core/logger.hpp"
21#include "../core/remote.hpp"
22#include "../core/wire.hpp"
23#include "../session/session.hpp"
24#include "bundle.hpp"
25#include "catalogue.hpp"
26#include "document.hpp"
27#include "file_store.hpp"
28#include "theme.hpp"
29#include "value.hpp"
30
31/// @file
32/// @brief The server side of the UI service (spec 5 §12, §12b, §13): `UiRegistry` collects an application's screens
33/// and files and builds a `ServedBundle`; `UiService` answers `ui-hello`, `ui-catalog` and `ui-fetch` from it
34/// as a `RemoteServer` envelope service.
35///
36/// Specified in `docs/spec/ui/service.md`.
37
38namespace morph::ui {
39
40/// @brief The application a bundle belongs to, as `ui-hello` names it.
42 /// @brief The application's id.
43 std::string id;
44 /// @brief The application's version.
45 std::string version;
46};
47
48/// @brief An action a document names, with the model type it runs on: what the authoriser is asked about.
49struct ActionRef {
50 /// @brief The model's registered type id.
51 std::string modelType;
52 /// @brief The action id.
53 std::string action;
54
55 /// @brief Orders by model type, then action.
56 /// @return The ordering.
57 auto operator<=>(ActionRef const&) const = default;
58};
59
60/// @brief One file a bundle serves, with every manifest entry whose bytes it is.
61struct ServedFile {
62 /// @brief One manifest entry that names the file.
63 struct Holder {
64 /// @brief The file kind, or `manifest` for the manifest itself.
65 std::string kind;
66 /// @brief The file id.
67 std::string id;
68 /// @brief Whether the entry is public.
69 bool isPublic = false;
70 /// @brief For a screen, its index in `ServedBundle::screens`.
71 std::optional<std::size_t> screen;
72 };
73
74 /// @brief The file's bytes.
75 std::string bytes;
76 /// @brief The entries that name these bytes; two entries with equal bytes share one file.
77 std::vector<Holder> holders;
78};
79
80/// @brief A screen as the bundle serves it.
82 /// @brief The catalog entry, as registered.
84 /// @brief The document's content hash; empty for an entry without a document.
85 std::string hash;
86 /// @brief The queries the document declares at its top level: the principal may see the screen when it may run
87 /// every one (spec 5 §12b).
88 std::vector<ActionRef> topQueries;
89 /// @brief Every action a query, a mutation or a model-bound node names, at any scope.
90 std::vector<ActionRef> actions;
91 /// @brief Every model type an alias names, at any scope.
92 std::vector<std::string> modelTypes;
93};
94
95/// @brief An application's bundle, built and immutable: what `UiService` serves.
97 /// @brief The application.
99 /// @brief The vocabularies the bundle needs, sorted; always `expr/1` and `ui/1`.
100 std::vector<std::string> vocabularies;
101 /// @brief The manifest's text.
102 std::string manifest;
103 /// @brief The manifest's content hash, which `ui-hello` replies with.
104 std::string digest;
105 /// @brief Every file by its content hash, the manifest included.
106 std::map<std::string, ServedFile, std::less<>> files;
107 /// @brief The catalog's screens, in registration order.
108 std::vector<ServedScreen> screens;
109 /// @brief The actions the app document names.
110 std::vector<ActionRef> appActions;
111 /// @brief The model types the app document names.
112 std::vector<std::string> appModelTypes;
113 /// @brief Every action any served document names, sorted and unique: the candidates of `permitted`.
114 std::vector<ActionRef> allActions;
115 /// @brief The model facts the registry's catalog gave, for every model type a document names; absent when it
116 /// has no model lookup.
117 std::optional<std::map<std::string, ModelInfo, std::less<>>> models;
118 /// @brief The action facts the registry's catalog gave, for every action a document names; absent when it has
119 /// no action lookup.
120 std::optional<std::map<std::string, ActionInfo, std::less<>>> actions;
121 /// @brief The view documents the registry's catalog gave, by the action of their `v-query`, for every action a
122 /// document names; absent when it has no view lookup.
123 std::optional<std::map<std::string, std::string, std::less<>>> views;
124 /// @brief The wizard documents the registry's catalog gave, by the action of their first step, for every action
125 /// a document names; absent when it has no wizard lookup.
126 std::optional<std::map<std::string, std::string, std::less<>>> wizards;
127};
128
129namespace detail {
130
131/// @brief Whether @p bytes can be served: within `kMaxFileBytes`, and within the envelope bound once escaped into
132/// an `ok` reply.
133/// @param bytes The file.
134/// @return Why it cannot, or nothing.
135[[nodiscard]] inline std::optional<std::string> checkServable(std::string const& bytes) {
136 if (bytes.size() > kMaxFileBytes) {
137 return "the file is larger than " + std::to_string(kMaxFileBytes) + " bytes";
138 }
139 if (::morph::wire::encode(::morph::wire::makeOk(0, bytes)).size() > ::morph::wire::kMaxEnvelopeBytes) {
140 return std::string{"the file does not fit one envelope once escaped"};
141 }
142 return std::nullopt;
143}
144
145/// @brief Checks a catalog entry's own fields.
146/// @param entry The entry.
147/// @return Why it is refused, or nothing.
148[[nodiscard]] inline std::optional<std::string> checkEntry(CatalogEntry const& entry) {
149 if (entry.id.empty()) {
150 return std::string{"a catalog entry has an id"};
151 }
152 if (auto reason = checkName(entry.id, "a catalog entry id")) {
153 return reason;
154 }
155 if (entry.whenDenied != "hide" && entry.whenDenied != "disable") {
156 return "`whenDenied` is `hide` or `disable`, not `" + entry.whenDenied + "`";
157 }
158 return std::nullopt;
159}
160
161// NOLINTBEGIN(misc-no-recursion): view nodes nest, and the walk follows them; parseJson bounds the depth at 64.
162
163/// @brief Collects the actions and model types a loaded document names, resolving each query's, mutation's and
164/// model-bound node's alias through the enclosing scopes.
165///
166/// Load-time validation has already resolved every alias and requires every alias to name a type, so each lookup
167/// here finds one.
169public:
170 /// @brief Walks @p document.
171 /// @param document The loaded document.
172 explicit DocumentActions(Document const& document) {
173 enter(document.scope, true);
174 if (document.view) {
175 node(*document.view);
176 }
177 }
178
179 /// @brief The top-level queries.
180 /// @return Them, in declaration order.
181 [[nodiscard]] std::vector<ActionRef> const& topQueries() const noexcept { return _top; }
182
183 /// @brief Every action named.
184 /// @return Them, sorted and unique.
185 [[nodiscard]] std::vector<ActionRef> actions() const { return {_all.begin(), _all.end()}; }
186
187 /// @brief Every model type named.
188 /// @return Them, sorted and unique.
189 [[nodiscard]] std::vector<std::string> modelTypes() const { return {_models.begin(), _models.end()}; }
190
191private:
192 void enter(ScopeDecl const& scope, bool top) {
193 for (ModelDecl const& model : scope.models) {
194 _types.try_emplace(model.name).first->second.push_back(model.type);
195 _models.insert(model.type);
196 }
197 for (QueryDecl const& query : scope.queries) {
198 ActionRef ref{.modelType = resolve(query.model), .action = query.action};
199 if (top) {
200 _top.push_back(ref);
201 }
202 _all.insert(std::move(ref));
203 }
204 for (MutationDecl const& mutation : scope.mutations) {
205 _all.insert(ActionRef{.modelType = resolve(mutation.model), .action = mutation.action});
206 }
207 }
208
209 void leave(ScopeDecl const& scope) {
210 for (ModelDecl const& model : scope.models) {
211 _types.at(model.name).pop_back();
212 }
213 }
214
215 void node(DocNode const& view) {
216 if (view.scope) {
217 enter(*view.scope, false);
218 }
219 if (std::string_view const model = view.name("model"); !model.empty()) {
220 _all.insert(ActionRef{.modelType = resolve(model), .action = std::string{view.name("action")}});
221 }
222 for (auto const& [key, children] : view.children) {
223 static_cast<void>(key);
224 for (DocNodePtr const& child : children) {
225 node(*child);
226 }
227 }
228 for (auto const& [key, parts] : view.parts) {
229 static_cast<void>(key);
230 for (DocNodePtr const& part : parts) {
231 node(*part);
232 }
233 }
234 if (view.scope) {
235 leave(*view.scope);
236 }
237 }
238
239 /// @brief The type of the innermost alias named @p alias. Load-time validation has resolved it already, so the
240 /// lookup cannot miss; `at` throws rather than read past the end if that ever changes.
241 [[nodiscard]] std::string const& resolve(std::string_view alias) const {
242 std::vector<std::string> const& types = _types.at(std::string{alias});
243 return types.at(types.size() - 1);
244 }
245
246 /// @brief The types an alias names in the open scopes, innermost last.
247 std::map<std::string, std::vector<std::string>, std::less<>> _types;
248 std::vector<ActionRef> _top;
249 std::set<ActionRef> _all;
250 std::set<std::string> _models;
251};
252
253// NOLINTEND(misc-no-recursion)
254
255/// @brief Whether the principal of @p session may run every action of @p refs.
256/// @param authorizer The authoriser.
257/// @param session The session, its principal verified.
258/// @param refs The actions.
259/// @return Whether it may run all of them; `true` for none.
260[[nodiscard]] inline bool permitsAll(::morph::session::IAuthorizer const& authorizer,
261 ::morph::session::Context const& session, std::vector<ActionRef> const& refs) {
262 return std::ranges::all_of(
263 refs, [&](ActionRef const& ref) { return authorizer.authorize(session, ref.modelType, ref.action); });
264}
265
266} // namespace detail
267
268/// @brief A file of the bundle that is not a screen, as `UiRegistry::addFile` takes it.
270 /// @brief `app`, `theme`, `messages`, `component` or `icon`.
271 std::string kind;
272 /// @brief The file's id: `app` for the app document, or a theme's, a catalogue's or a component's name.
273 std::string id;
274 /// @brief The file, served byte for byte.
275 std::string bytes;
276 /// @brief Whether anyone the `ui-hello` gate admits may fetch it, before sign-in.
277 bool isPublic = false;
278};
279
280/// @brief Collects an application's screens and files and builds the bundle the UI service serves.
281///
282/// Built once, before the service answers anything; `build` copies everything into an immutable `ServedBundle`.
283/// A screen authored with the C++ builders is added by its emitted text, so an authoring registry feeds this one
284/// through `addScreen` without either depending on the other.
286public:
287 /// @brief An empty registry.
288 /// @param application The application `ui-hello` names.
289 /// @param catalog The catalog facts: load-time validation reads them at registration, and the
290 /// `ui-catalog` reply carries them to the client. A lookup left empty is neither checked nor
291 /// served.
292 explicit UiRegistry(ApplicationInfo application, Catalog catalog = {})
293 : _application{std::move(application)}, _catalog{std::move(catalog)} {}
294
295 /// @brief Registers a screen: its catalog entry and its document.
296 ///
297 /// Refused when the entry's id is empty, longer than `kMaxNameBytes`, already registered, or not the document's
298 /// id; when `whenDenied` is neither `hide` nor `disable`; when the entry names a `host` (a host screen has no
299 /// document: `addEntry`); when the document is `app` (`addFile`); when the text is too large to serve; and when
300 /// load-time validation refuses the document.
301 /// @param entry The catalog entry.
302 /// @param text The document's JSON text, served byte for byte.
303 /// @return Nothing, or the refusal.
304 std::expected<void, Refusal> addScreen(CatalogEntry entry, std::string text) {
305 std::string const screenId = entry.id;
306 std::size_t const size = text.size();
307 auto added = addScreenImpl(std::move(entry), std::move(text));
308 if (added) {
309 ::morph::log::logDebug("[ui-serve] added the screen `{}` ({} bytes)", screenId, size);
310 } else {
311 ::morph::log::logWarn("[ui-serve] refused a screen: {}", added.error().message());
312 }
313 return added;
314 }
315
316 /// @brief Lists an entry that has no document: a host screen (`host`), or one the application has not built
317 /// (`available: false`).
318 ///
319 /// Refused on the same entry rules as `addScreen`, and when the entry is neither a host screen nor unavailable.
320 /// @param entry The catalog entry.
321 /// @return Nothing, or the refusal.
322 std::expected<void, Refusal> addEntry(CatalogEntry entry) {
323 std::string const entryId = entry.id;
324 auto added = addEntryImpl(std::move(entry));
325 if (added) {
326 ::morph::log::logDebug("[ui-serve] added the entry `{}`", entryId);
327 } else {
328 ::morph::log::logWarn("[ui-serve] refused an entry: {}", added.error().message());
329 }
330 return added;
331 }
332
333 /// @brief Adds a file that is not a screen: the app document (`app`, whose id is `app`), a theme (`theme`), a
334 /// message catalogue (`messages`), a delivered component (`component`) or an icon (`icon`).
335 ///
336 /// Refused for another kind, an empty or over-long id, a kind and id already added, a file too large to serve,
337 /// an app document that load-time validation refuses or whose id is not `app`, a message catalogue whose id is
338 /// not `i18n/LOCALE.json`, that `parseCatalogue` refuses, or that has a pattern outside the `ui/1` subset, and a
339 /// theme file that is neither a theme index `parseThemeIndex` takes nor a token file `parseTokens` takes, and an
340 /// icon that is not SVG text or is larger than `kMaxIconBytes`.
341 /// @param file The file.
342 /// @return Nothing, or the refusal.
343 std::expected<void, Refusal> addFile(BundleFile file) {
344 std::string const kind = file.kind;
345 std::string const fileId = file.id;
346 std::size_t const size = file.bytes.size();
347 auto added = addFileImpl(std::move(file));
348 if (added) {
349 ::morph::log::logDebug("[ui-serve] added the {} `{}` ({} bytes)", kind, fileId, size);
350 } else {
351 ::morph::log::logWarn("[ui-serve] refused a file: {}", added.error().message());
352 }
353 return added;
354 }
355
356 /// @brief Adds a vocabulary the bundle needs beyond `ui/1` and `expr/1`, such as `forms/1` or `files/1`. A
357 /// client that does not speak it is refused at `ui-hello`.
358 /// @param vocabulary The vocabulary.
359 void requireVocabulary(std::string vocabulary) { _vocabularies.insert(std::move(vocabulary)); }
360
361 /// @brief Builds the bundle.
362 ///
363 /// Refused when a screen names a delivered component (`qml`) whose hash no `component` file has, when the
364 /// bundle holds more than `kMaxBundleFiles` files or entries, and when it needs more than `kMaxListItems`
365 /// vocabularies or a vocabulary name longer than `kMaxNameBytes`.
366 /// @return The bundle, or the refusal.
367 [[nodiscard]] std::expected<std::shared_ptr<ServedBundle const>, Refusal> build() const {
368 auto built = buildImpl();
369 if (built) {
370 ::morph::log::logDebug("[ui-serve] built the bundle of `{}`: {} files, digest {}", _application.id,
371 (*built)->files.size(), (*built)->digest);
372 } else {
373 ::morph::log::logWarn("[ui-serve] refused to build: {}", built.error().message());
374 }
375 return built;
376 }
377
378private:
379 /// @brief Registers a screen; the public `addScreen` logs its outcome.
380 /// @param entry The catalog entry.
381 /// @param text The document's JSON text.
382 /// @return Nothing, or the refusal.
383 std::expected<void, Refusal> addScreenImpl(CatalogEntry entry, std::string text) {
384 if (auto refusal = checkNewEntry(entry)) {
385 return std::unexpected(std::move(*refusal));
386 }
387 if (entry.host) {
388 return refuse(entry.id, "a host screen has no document; it is added with addEntry");
389 }
390 if (auto reason = detail::checkServable(text)) {
391 return refuse(entry.id, std::move(*reason));
392 }
393 auto loaded = loadDocumentText(text, LoadOptions{.catalog = _catalog});
394 if (!loaded) {
395 return std::unexpected(std::move(loaded.error()));
396 }
397 if (loaded->id != entry.id) {
398 return refuse(entry.id, "the entry's id is not the document's id `" + loaded->id + "`");
399 }
400 if (!loaded->view) {
401 return refuse(entry.id, "the app document is added with addFile");
402 }
403 logWarnings(*loaded);
404 detail::DocumentActions const named{*loaded};
405 Screen screen{.served = ServedScreen{.entry = std::move(entry),
406 .hash = contentHash(text),
407 .topQueries = named.topQueries(),
408 .actions = named.actions(),
409 .modelTypes = named.modelTypes()},
410 .text = std::move(text),
411 .components = {}};
412 for (auto const& [name, component] : loaded->components) {
413 static_cast<void>(name);
414 if (!component.qml.empty()) {
415 screen.components.emplace_back(pointerAppend(component.path, "qml"), component.qml);
416 }
417 }
418 _screens.push_back(std::move(screen));
419 return {};
420 }
421
422 /// @brief Registers an entry that has no document; the public `addEntry` logs its outcome.
423 /// @param entry The catalog entry.
424 /// @return Nothing, or the refusal.
425 std::expected<void, Refusal> addEntryImpl(CatalogEntry entry) {
426 if (auto refusal = checkNewEntry(entry)) {
427 return std::unexpected(std::move(*refusal));
428 }
429 if (!entry.host && entry.available) {
430 return refuse(entry.id, "an entry without a document is a host screen or unavailable");
431 }
432 _screens.push_back(Screen{
433 .served =
434 ServedScreen{.entry = std::move(entry), .hash = {}, .topQueries = {}, .actions = {}, .modelTypes = {}},
435 .text = {},
436 .components = {}});
437 return {};
438 }
439
440 /// @brief Adds a file; the public `addFile` logs its outcome.
441 /// @param file The file.
442 /// @return Nothing, or the refusal.
443 std::expected<void, Refusal> addFileImpl(BundleFile file) {
444 if (file.kind != kAppFile && file.kind != kThemeFile && file.kind != kMessagesFile &&
445 file.kind != kComponentFile && file.kind != kIconFile) {
446 return refuse(file.id,
447 "a file is an app document, a theme, a message catalogue, a component or an icon, "
448 "not `" +
449 file.kind + "`");
450 }
451 if (file.id.empty()) {
452 return refuse(file.id, "a file has an id");
453 }
454 if (auto reason = detail::checkName(file.id, "a file id")) {
455 return refuse(file.id, std::move(*reason));
456 }
457 if (std::ranges::any_of(
458 _files, [&file](BundleFile const& added) { return added.kind == file.kind && added.id == file.id; })) {
459 return refuse(file.id, "the " + file.kind + " `" + file.id + "` is already added");
460 }
461 if (auto reason = detail::checkServable(file.bytes)) {
462 return refuse(file.id, std::move(*reason));
463 }
464 if (auto added = addKind(file); !added) {
465 return added;
466 }
467 _files.push_back(std::move(file));
468 return {};
469 }
470
471 /// @brief Builds the bundle; the public `build` logs its outcome.
472 /// @return The bundle, or the refusal.
473 [[nodiscard]] std::expected<std::shared_ptr<ServedBundle const>, Refusal> buildImpl() const {
474 if (auto refusal = checkBuildable()) {
475 return std::unexpected(std::move(*refusal));
476 }
477 auto bundle = std::make_shared<ServedBundle>();
478 bundle->application = _application;
479 bundle->vocabularies.assign(_vocabularies.begin(), _vocabularies.end());
480 bundle->appActions = _appActions;
481 bundle->appModelTypes = _appModelTypes;
482 serveFiles(*bundle);
483 addFacts(*bundle);
484 return bundle;
485 }
486
487 /// @brief Checks every theme index: each combination of its variants, densities and surfaces over `base`
488 /// resolves from the bundle's token files (`checkTheme`).
489 /// @return The refusal, naming the index and the combination, or nothing.
490 [[nodiscard]] std::optional<Refusal> checkThemes() const {
491 TokenFileLookup const lookup = [this](std::string_view fileId) -> TokenSet const* {
492 auto const found = _tokenFiles.find(fileId);
493 return found == _tokenFiles.end() ? nullptr : &found->second;
494 };
495 for (auto const& [indexId, index] : _themeIndexes) {
496 if (auto reason = checkTheme(index, lookup)) {
497 return Refusal{.document = indexId, .path = {}, .reason = "the theme: " + std::move(*reason)};
498 }
499 for (auto const& [name, fileId] : index.icons) {
500 bool const held = std::ranges::any_of(
501 _files, [&fileId](BundleFile const& file) { return file.kind == kIconFile && file.id == fileId; });
502 if (!held) {
503 return Refusal{.document = indexId,
504 .path = {},
505 .reason = std::format("the theme index names the icon `{}` as `{}`, which is not "
506 "an icon file of the bundle",
507 name, fileId)};
508 }
509 }
510 }
511 return std::nullopt;
512 }
513
514 /// @brief A registered screen and what `build` needs of its document.
515 struct Screen {
516 /// @brief What the bundle serves.
517 ServedScreen served;
518 /// @brief The document's text; empty for an entry without one.
519 std::string text;
520 /// @brief The delivered components the document names: their `qml` pointer and hash.
521 std::vector<std::pair<std::string, std::string>> components;
522 };
523
524 // The checks and the indexing one kind of file adds to addFile's common ones.
525 std::expected<void, Refusal> addKind(BundleFile const& file) {
526 if (file.kind == kMessagesFile) {
527 return checkCatalogue(file);
528 }
529 if (file.kind == kThemeFile) {
530 return addTheme(file);
531 }
532 if (file.kind == kAppFile) {
533 return addApp(file);
534 }
535 if (file.kind == kIconFile) {
536 return checkIcon(file);
537 }
538 return {};
539 }
540
541 // An icon is SVG text, small enough for a theme to carry a set of them.
542 [[nodiscard]] static std::expected<void, Refusal> checkIcon(BundleFile const& file) {
543 if (file.bytes.size() > kMaxIconBytes) {
544 return refuse(file.id, std::format("an icon is at most {} bytes of SVG; this one is {}", kMaxIconBytes,
545 file.bytes.size()));
546 }
547 if (!file.bytes.contains("<svg")) {
548 return refuse(file.id, "an icon is SVG text, and this one has no <svg> element");
549 }
550 return {};
551 }
552
553 [[nodiscard]] static std::expected<void, Refusal> checkCatalogue(BundleFile const& file) {
554 if (!catalogueLocale(file.id)) {
555 return refuse(file.id, "a message catalogue's id is i18n/<locale>.json");
556 }
557 auto catalogue = parseCatalogue(file.bytes);
558 if (!catalogue) {
559 return refuse(file.id, std::move(catalogue.error()));
560 }
561 if (!catalogue->dropped.empty()) {
562 return refuse(file.id, "a message is outside the ui/1 subset: " + catalogue->dropped.front());
563 }
564 return {};
565 }
566
567 std::expected<void, Refusal> addTheme(BundleFile const& file) {
568 if (isThemeIndex(file.bytes)) {
569 auto index = parseThemeIndex(file.bytes);
570 if (!index) {
571 return refuse(file.id, "the theme index: " + index.error());
572 }
573 _themeIndexes.emplace_back(file.id, std::move(*index));
574 return {};
575 }
576 auto tokens = parseTokens(file.bytes);
577 if (!tokens) {
578 return refuse(file.id, "the token file: " + tokens.error());
579 }
580 _tokenFiles.emplace(file.id, std::move(*tokens));
581 return {};
582 }
583
584 std::expected<void, Refusal> addApp(BundleFile const& file) {
585 auto loaded = loadDocumentText(file.bytes, LoadOptions{.catalog = _catalog});
586 if (!loaded) {
587 return std::unexpected(std::move(loaded.error()));
588 }
589 if (loaded->id != kAppFile) {
590 return refuse(file.id, "the app document's id is `app`");
591 }
592 logWarnings(*loaded);
593 detail::DocumentActions const named{*loaded};
594 _appActions = named.actions();
595 _appModelTypes = named.modelTypes();
596 return {};
597 }
598
599 /// @brief Logs what load-time validation found in @p document that does not refuse it.
600 /// @param document The loaded document.
601 static void logWarnings(Document const& document) {
602 for (LoadWarning const& warning : document.warnings) {
603 morph::log::logWarn(warning.message(document.id));
604 }
605 }
606
607 /// @brief A refusal of the registration of @p document.
608 /// @param document The screen or file id.
609 /// @param reason Why.
610 /// @return The refusal, as an `std::unexpected`.
611 // NOLINTNEXTLINE(bugprone-easily-swappable-parameters): the id first, then the reason, at every call.
612 [[nodiscard]] static std::unexpected<Refusal> refuse(std::string document, std::string reason) {
613 return std::unexpected(Refusal{.document = std::move(document), .path = {}, .reason = std::move(reason)});
614 }
615
616 /// @brief Checks an entry about to be registered: its own fields, and that its id is new.
617 /// @param entry The entry.
618 /// @return The refusal, or nothing.
619 [[nodiscard]] std::optional<Refusal> checkNewEntry(CatalogEntry const& entry) const {
620 if (auto reason = detail::checkEntry(entry)) {
621 return Refusal{.document = entry.id, .path = {}, .reason = std::move(*reason)};
622 }
623 if (std::ranges::any_of(_screens, [&](Screen const& screen) { return screen.served.entry.id == entry.id; })) {
624 return Refusal{.document = entry.id, .path = {}, .reason = "a catalog entry with this id is registered"};
625 }
626 return std::nullopt;
627 }
628
629 /// @brief What `build` refuses: a delivered component missing from the bundle, a theme some combination of which
630 /// does not resolve, and the bounds.
631 /// @return The refusal, or nothing.
632 [[nodiscard]] std::optional<Refusal> checkBuildable() const {
633 if (auto refusal = checkThemes()) {
634 return refusal;
635 }
636 std::set<std::string, std::less<>> componentHashes;
637 for (BundleFile const& file : _files) {
638 if (file.kind == kComponentFile) {
639 componentHashes.insert(contentHash(file.bytes));
640 }
641 }
642 for (Screen const& screen : _screens) {
643 for (auto const& [path, hash] : screen.components) {
644 if (!componentHashes.contains(hash)) {
645 return Refusal{.document = screen.served.entry.id,
646 .path = path,
647 .reason = "the delivered component is not in the bundle"};
648 }
649 }
650 }
651 if (_screens.size() > kMaxBundleFiles || _screens.size() + _files.size() > kMaxBundleFiles) {
652 return Refusal{
653 .document = {},
654 .path = {},
655 .reason = "the bundle holds more than " + std::to_string(kMaxBundleFiles) + " files or entries"};
656 }
657 std::vector<std::string> const vocabularies{_vocabularies.begin(), _vocabularies.end()};
658 if (auto reason = detail::checkNames(vocabularies, kMaxListItems, "the vocabularies", "a vocabulary")) {
659 return Refusal{.document = {}, .path = {}, .reason = std::move(*reason)};
660 }
661 return std::nullopt;
662 }
663
664 /// @brief Fills @p bundle's screens, files, manifest and digest.
665 /// @param bundle The bundle being built.
666 void serveFiles(ServedBundle& bundle) const {
667 Manifest manifest{.application = _application.id, .version = _application.version, .files = {}};
668 auto serve = [&bundle, &manifest](BundleFile const& file, std::optional<std::size_t> screen) {
669 std::string const hash = contentHash(file.bytes);
670 manifest.files.push_back(ManifestFile{
671 .id = file.id, .kind = file.kind, .hash = hash, .size = file.bytes.size(), .isPublic = file.isPublic});
672 ServedFile& served = bundle.files.try_emplace(hash).first->second;
673 served.bytes = file.bytes;
674 served.holders.push_back(
675 ServedFile::Holder{.kind = file.kind, .id = file.id, .isPublic = file.isPublic, .screen = screen});
676 };
677 for (Screen const& screen : _screens) {
678 if (!screen.served.hash.empty()) {
679 serve(BundleFile{.kind = std::string{kScreenFile},
680 .id = screen.served.entry.id,
681 .bytes = screen.text,
682 .isPublic = screen.served.entry.isPublic},
683 bundle.screens.size());
684 }
685 bundle.screens.push_back(screen.served);
686 }
687 for (BundleFile const& file : _files) {
688 serve(file, std::nullopt);
689 }
690 std::ranges::sort(manifest.files, [](ManifestFile const& lhs, ManifestFile const& rhs) {
691 return std::tie(lhs.kind, lhs.id) < std::tie(rhs.kind, rhs.id);
692 });
693 bundle.manifest = manifestText(manifest);
694 bundle.digest = contentHash(bundle.manifest);
695 bundle.files.insert_or_assign(
696 bundle.digest,
697 ServedFile{.bytes = bundle.manifest,
698 .holders = {ServedFile::Holder{
699 .kind = "manifest", .id = _application.id, .isPublic = true, .screen = std::nullopt}}});
700 }
701
702 /// @brief Fills @p bundle's action candidates and, from the registry's catalog, its facts.
703 /// @param bundle The bundle being built, its screens already served.
704 void addFacts(ServedBundle& bundle) const {
705 std::set<ActionRef> all{_appActions.begin(), _appActions.end()};
706 std::set<std::string, std::less<>> modelTypes{_appModelTypes.begin(), _appModelTypes.end()};
707 for (ServedScreen const& screen : bundle.screens) {
708 all.insert(screen.actions.begin(), screen.actions.end());
709 modelTypes.insert(screen.modelTypes.begin(), screen.modelTypes.end());
710 }
711 bundle.allActions.assign(all.begin(), all.end());
712 if (_catalog.model) {
713 auto& known = bundle.models.emplace();
714 for (std::string const& type : modelTypes) {
715 // Load-time validation refused every document naming a type the lookup does not know.
716 known.emplace(type, _catalog.model(type).value_or(ModelInfo{}));
717 }
718 }
719 if (_catalog.action) {
720 auto& known = bundle.actions.emplace();
721 for (ActionRef const& ref : all) {
722 // Load-time validation refused every document naming an action the lookup does not know.
723 known.emplace(ref.action, _catalog.action(ref.action).value_or(ActionInfo{}));
724 }
725 }
726 auto const documents = [&all](auto const& lookup, auto& out) {
727 if (!lookup) {
728 return;
729 }
730 auto& known = out.emplace();
731 for (ActionRef const& ref : all) {
732 if (std::optional<std::string> document = lookup(ref.action)) {
733 known.emplace(ref.action, std::move(*document));
734 }
735 }
736 };
737 documents(_catalog.view, bundle.views);
738 documents(_catalog.wizard, bundle.wizards);
739 }
740
741 ApplicationInfo _application;
742 Catalog _catalog;
743 std::vector<Screen> _screens;
744 std::vector<BundleFile> _files;
745 /// @brief The theme indexes added, by file id, as `addFile` parsed them.
746 std::vector<std::pair<std::string, ThemeIndex>> _themeIndexes;
747 /// @brief The theme token files added, by file id, as `addFile` parsed them.
748 std::map<std::string, TokenSet, std::less<>> _tokenFiles;
749 std::vector<ActionRef> _appActions;
750 std::vector<std::string> _appModelTypes;
751 std::set<std::string, std::less<>> _vocabularies{std::string{kExprVocabulary}, std::string{kVocabulary}};
752};
753
754/// @brief Whether an authoriser is asked about one of the UI service's reads: `ui`'s `hello`, `catalog` or `fetch`.
755///
756/// An application that serves a login screen admits these for a session with no principal yet, so a client can greet
757/// the service, read the catalog and fetch the login screen. Admitting them discloses no other screen: `ui-catalog`
758/// lists, and `ui-fetch` serves, a non-public screen only to a principal who may run its queries, which the
759/// authoriser still decides.
760/// @param modelType The model type asked about.
761/// @param actionType The action asked about.
762/// @return Whether it is one of those reads.
763[[nodiscard]] constexpr bool isUiServiceRead(std::string_view modelType, std::string_view actionType) noexcept {
764 return modelType == "ui" && (actionType == "hello" || actionType == "catalog" || actionType == "fetch");
765}
766
767/// @brief The UI service: answers `ui-hello`, `ui-catalog` and `ui-fetch` from one `ServedBundle`, and `ui-evaluate`
768/// through the server's `ActionDispatcher`, as an envelope service of a `RemoteServer`
769/// (`ServerConfig::services`).
770///
771/// Every request is authorised (`docs/spec/ui/service.md`, "Authorisation"):
772///
773/// | Kind | Gate |
774/// |---|---|
775/// | `ui-hello` | `authorize(session, "ui", "hello")` |
776/// | `ui-catalog` | `authorize(session, "ui", "catalog")` |
777/// | `ui-fetch` of the manifest or a public file | the `ui-hello` gate |
778/// | `ui-fetch` of any other file | `authorize(session, "ui", "fetch")`; for a screen, also the catalog's rule |
779/// | `ui-evaluate` | `authorize(session, modelType, actionType)`: as the action it evaluates |
780///
781/// A refusal is `err "unauthorized"`. A request body that is too large, malformed or carries a must-understand key
782/// is refused with the parser's reason.
784public:
785 /// @brief Serves @p bundle, and evaluates through @p dispatcher.
786 /// @param bundle The bundle; shared, never changed.
787 /// @param dispatcher The dispatcher `ui-evaluate` reads the actions from: the server's own. Borrowed: it must
788 /// outlive the service.
789 /// @throws std::invalid_argument if @p bundle is null.
790 /// @param files The file side channel's store; `nullptr` serves no `ui-file`.
791 explicit UiService(std::shared_ptr<ServedBundle const> bundle,
792 ::morph::model::detail::ActionDispatcher& dispatcher MORPH_LIFETIMEBOUND =
793 ::morph::model::detail::defaultDispatcher(),
794 std::shared_ptr<IFileStore> files = nullptr)
795 : _bundle{std::move(bundle)}, _dispatcher{&dispatcher}, _files{std::move(files)} {
796 if (!_bundle) {
797 throw std::invalid_argument("UiService: the bundle is null");
798 }
799 }
800
801 /// @brief Whether @p kind is one of the service's kinds.
802 /// @param kind The envelope kind.
803 /// @return `true` for `ui-hello`, `ui-catalog`, `ui-fetch` and `ui-evaluate`.
804 [[nodiscard]] bool serves(std::string_view kind) const override {
805 return kind == kHelloKind || kind == kCatalogKind || kind == kFetchKind || kind == kEvaluateKind ||
806 (kind == kFileKind && _files != nullptr);
807 }
808
809 /// @brief Answers one request with no connection behind it: a `ui-hello` offer of events is accepted in the
810 /// reply, and recorded nowhere.
811 /// @param request The envelope, its principal verified.
812 /// @param authorizer The server's authoriser.
813 /// @return The encoded reply.
814 [[nodiscard]] std::string handle(::morph::wire::Envelope const& request,
815 ::morph::session::IAuthorizer const& authorizer) override {
817 return handle(request, authorizer, facts);
818 }
819
820 /// @brief Answers one request; a `ui-hello` that offers events and is answered `ok` sets `facts.events`.
821 ///
822 /// A hello that does not offer them leaves the facts as they are, so a later hello on the same connection, such
823 /// as a development reload's poll, does not withdraw what the first negotiated.
824 /// @param request The envelope, its principal verified.
825 /// @param authorizer The server's authoriser.
826 /// @param facts The facts of the connection the request arrived on.
827 /// @return The encoded reply.
828 [[nodiscard]] std::string handle(::morph::wire::Envelope const& request,
829 ::morph::session::IAuthorizer const& authorizer,
830 ::morph::backend::ConnectionFacts& facts) override {
831 if (request.kind == kHelloKind) {
832 return hello(request, authorizer, facts);
833 }
834 if (request.kind == kCatalogKind) {
835 return catalog(request, authorizer);
836 }
837 if (request.kind == kEvaluateKind) {
838 return evaluate(request, authorizer);
839 }
840 if (request.kind == kFileKind) {
841 return file(request, authorizer);
842 }
843 return fetch(request, authorizer);
844 }
845
846private:
847 /// @brief The model types and actions of the documents a principal may fetch, whose facts the catalog carries.
848 struct Fetchable {
849 std::set<std::string, std::less<>> modelTypes;
850 std::set<std::string, std::less<>> actions;
851 };
852
853 [[nodiscard]] static std::string ok(::morph::wire::Envelope const& request, std::string body) {
854 return ::morph::wire::encode(::morph::wire::makeOk(request.callId, std::move(body)));
855 }
856
857 [[nodiscard]] static std::string err(::morph::wire::Envelope const& request, std::string message) {
858 return ::morph::wire::encode(::morph::wire::makeErr(std::move(message), request.callId));
859 }
860
861 /// @brief An encoded reply and the fields the service's log line carries: what was asked for, and the outcome.
862 struct Answer {
863 /// @brief The encoded reply.
864 std::string encoded;
865 /// @brief The hash or application the request named.
866 std::string subject;
867 /// @brief `ok`, or the refusal the reply carries.
868 std::string outcome;
869 };
870
871 [[nodiscard]] static bool gate(::morph::session::IAuthorizer const& authorizer,
872 ::morph::wire::Envelope const& request, std::string_view action) {
873 return authorizer.authorize(request.session, kServiceModel, action);
874 }
875
876 /// @brief Answers a `ui-hello` and logs the outcome at debug.
877 /// @param request The envelope.
878 /// @param authorizer The server's authoriser.
879 /// @param facts The facts of the connection the request arrived on.
880 /// @return The encoded reply.
881 [[nodiscard]] std::string hello(::morph::wire::Envelope const& request,
882 ::morph::session::IAuthorizer const& authorizer,
883 ::morph::backend::ConnectionFacts& facts) const {
884 Answer answer = helloAnswer(request, authorizer, facts);
885 ::morph::log::logDebug("[ui-hello] `{}`: {}", answer.subject, answer.outcome);
886 return std::move(answer.encoded);
887 }
888
889 /// @brief Answers a `ui-hello` without logging it.
890 /// @param request The envelope.
891 /// @param authorizer The server's authoriser.
892 /// @param facts The facts of the connection the request arrived on.
893 /// @return The reply and its log fields.
894 [[nodiscard]] Answer helloAnswer(::morph::wire::Envelope const& request,
895 ::morph::session::IAuthorizer const& authorizer,
896 ::morph::backend::ConnectionFacts& facts) const {
897 std::string const application = _bundle->application.id;
898 if (!gate(authorizer, request, kHelloAction)) {
899 return Answer{
900 .encoded = err(request, "unauthorized"), .subject = application, .outcome = "refused: unauthorized"};
901 }
902 auto client = parseHelloRequest(request.body);
903 if (!client) {
904 std::string const reason = std::move(client.error());
905 return Answer{.encoded = err(request, reason), .subject = application, .outcome = "refused: " + reason};
906 }
907 std::string missing;
908 for (std::string const& vocabulary : _bundle->vocabularies) {
909 if (std::ranges::find(client->vocabularies, vocabulary) == client->vocabularies.end()) {
910 missing += (missing.empty() ? "" : ", ") + vocabulary;
911 }
912 }
913 if (!missing.empty()) {
914 std::string const reason = "missing vocabulary: " + missing;
915 return Answer{.encoded = err(request, reason), .subject = application, .outcome = "refused: " + reason};
916 }
917 if (client->events) {
918 facts.events = true;
919 }
920 return Answer{.encoded = ok(request, detail::writeMessage(HelloReply{.application = _bundle->application.id,
921 .version = _bundle->application.version,
922 .vocabularies = _bundle->vocabularies,
923 .digest = _bundle->digest,
924 .events = client->events})),
925 .subject = application,
926 .outcome = "ok, digest " + _bundle->digest};
927 }
928
929 /// @brief Evaluates the action body @p request carries (spec 5 §7): decoded, reconciled and recomputed, never
930 /// executed, and answered with its field errors. Synchronous on the server strand, like the other kinds.
931 /// @param request The envelope: `modelType`, `actionType` and the action's JSON `body`.
932 /// @param authorizer The server's authoriser.
933 /// @return The encoded reply: an `EvaluateReply`, or `err` for a refusal or a body that does not decode.
934 [[nodiscard]] std::string evaluate(::morph::wire::Envelope const& request,
935 ::morph::session::IAuthorizer const& authorizer) const {
936 if (request.modelType.empty() || request.actionType.empty()) {
937 return err(request, "ui-evaluate names no modelType and actionType");
938 }
939 if (!authorizer.authorize(request.session, request.modelType, request.actionType)) {
940 return err(request, "unauthorized");
941 }
942 auto evaluation = _dispatcher->evaluate(request.modelType, request.actionType, request.body);
943 EvaluateReply reply{.body = {}, .errors = std::move(evaluation.errors), .ready = evaluation.ready};
944 if (auto const error = glz::read_json(reply.body, evaluation.body)) {
945 return err(request, "ui-evaluate: " + glz::format_error(error, evaluation.body));
946 }
947 return ok(request, detail::writeMessage(reply));
948 }
949
950 /// @brief One step of an upload or a download through the file side channel (spec 6 §18).
951 ///
952 /// `begin` is authorised as the mutation that will reference the file; every later step is authorised by the
953 /// upload id or reference, which belong to the principal that created them. A refusal of the store is `err` with
954 /// its text; an id or reference of another principal is `not found`, like one that does not exist.
955 /// @param request The envelope; its body is a `FileRequest`.
956 /// @param authorizer The server's authoriser.
957 /// @return The encoded reply: a `FileReply`, or `err`.
958 [[nodiscard]] std::string file(::morph::wire::Envelope const& request,
959 ::morph::session::IAuthorizer const& authorizer) const {
960 auto parsed = parseFileRequest(request.body);
961 if (!parsed) {
962 return err(request, std::move(parsed.error()));
963 }
964 FileRequest& step = *parsed;
965 std::string const& principal = request.session.principal;
966 auto const refused = [&request](FileError error) { return err(request, std::string{fileErrorText(error)}); };
967 FileReply reply;
968 if (step.op == "begin") {
969 if (step.model.empty() || step.action.empty()) {
970 return err(request, "ui-file begin names no model and action");
971 }
972 if (!authorizer.authorize(request.session, step.model, step.action)) {
973 return err(request, "unauthorized");
974 }
975 auto upload = _files->begin(
976 principal, FileInfo{.name = std::move(step.name), .type = std::move(step.type), .size = step.size});
977 if (!upload) {
978 return refused(upload.error());
979 }
980 reply.upload = std::move(*upload);
981 } else if (step.op == "chunk") {
982 auto bytes = ::morph::session::detail::base64UrlDecode(step.data);
983 if (!bytes) {
984 return err(request, "ui-file chunk data is not base64url");
985 }
986 if (bytes->size() > kMaxFileChunkBytes) {
987 return err(request, "ui-file chunk is longer than " + std::to_string(kMaxFileChunkBytes) + " bytes");
988 }
989 auto received = _files->append(principal, step.upload, step.offset, *bytes);
990 if (!received) {
991 return refused(received.error());
992 }
993 reply.received = *received;
994 } else if (step.op == "end") {
995 auto ref = _files->finish(principal, step.upload);
996 if (!ref) {
997 return refused(ref.error());
998 }
999 reply.ref = std::move(*ref);
1000 } else {
1001 auto slice = _files->read(principal, step.ref, step.offset, std::min(step.length, kMaxFileChunkBytes));
1002 if (!slice) {
1003 return refused(slice.error());
1004 }
1005 reply.data = ::morph::session::detail::base64UrlEncode(slice->bytes);
1006 reply.total = slice->info.size;
1007 reply.name = std::move(slice->info.name);
1008 reply.type = std::move(slice->info.type);
1009 }
1010 return ok(request, detail::writeMessage(reply));
1011 }
1012
1013 /// @brief Answers a `ui-catalog` and logs the outcome at debug.
1014 /// @param request The envelope.
1015 /// @param authorizer The server's authoriser.
1016 /// @return The encoded reply.
1017 [[nodiscard]] std::string catalog(::morph::wire::Envelope const& request,
1018 ::morph::session::IAuthorizer const& authorizer) const {
1019 Answer answer = catalogAnswer(request, authorizer);
1020 ::morph::log::logDebug("[ui-catalog] `{}`: {}", answer.subject, answer.outcome);
1021 return std::move(answer.encoded);
1022 }
1023
1024 /// @brief Answers a `ui-catalog` without logging it.
1025 /// @param request The envelope.
1026 /// @param authorizer The server's authoriser.
1027 /// @return The reply and its log fields.
1028 [[nodiscard]] Answer catalogAnswer(::morph::wire::Envelope const& request,
1029 ::morph::session::IAuthorizer const& authorizer) const {
1030 std::string const application = _bundle->application.id;
1031 if (!gate(authorizer, request, kCatalogAction)) {
1032 return Answer{
1033 .encoded = err(request, "unauthorized"), .subject = application, .outcome = "refused: unauthorized"};
1034 }
1035 CatalogReply reply;
1036 Fetchable const fetchable = listEntries(reply, request.session, authorizer);
1037 for (ActionRef const& ref : _bundle->allActions) {
1038 if (authorizer.authorize(request.session, ref.modelType, ref.action)) {
1039 reply.permitted.push_back(ref.action);
1040 }
1041 }
1042 if (_bundle->models) {
1043 auto& known = reply.models.emplace();
1044 for (auto const& [type, info] : *_bundle->models) {
1045 if (fetchable.modelTypes.contains(type)) {
1046 known.emplace(type, info);
1047 }
1048 }
1049 }
1050 if (_bundle->actions) {
1051 auto& known = reply.actions.emplace();
1052 for (auto const& [action, info] : *_bundle->actions) {
1053 if (fetchable.actions.contains(action)) {
1054 known.emplace(action, info);
1055 }
1056 }
1057 }
1058 // A collection's or wizard's document goes with the action that names it, to whoever may fetch that action.
1059 auto const documents = [&fetchable](auto const& served, auto& out) {
1060 if (!served) {
1061 return;
1062 }
1063 auto& known = out.emplace();
1064 for (auto const& [action, document] : *served) {
1065 if (fetchable.actions.contains(action)) {
1066 known.emplace(action, document);
1067 }
1068 }
1069 };
1070 documents(_bundle->views, reply.views);
1071 documents(_bundle->wizards, reply.wizards);
1072 return Answer{.encoded = ok(request, detail::writeMessage(reply)),
1073 .subject = application,
1074 .outcome = std::format("ok, {} entries", reply.entries.size())};
1075 }
1076
1077 /// @brief Lists the entries the principal sees into @p reply.
1078 /// @param reply The reply being built.
1079 /// @param session The session, its principal verified.
1080 /// @param authorizer The server's authoriser.
1081 /// @return What the documents the principal may fetch name.
1082 [[nodiscard]] Fetchable listEntries(CatalogReply& reply, ::morph::session::Context const& session,
1083 ::morph::session::IAuthorizer const& authorizer) const {
1084 Fetchable fetchable{.modelTypes = {_bundle->appModelTypes.begin(), _bundle->appModelTypes.end()},
1085 .actions = {}};
1086 for (ActionRef const& ref : _bundle->appActions) {
1087 fetchable.actions.insert(ref.action);
1088 }
1089 for (ServedScreen const& screen : _bundle->screens) {
1090 CatalogEntry entry = screen.entry;
1091 if (entry.available && !detail::permitsAll(authorizer, session, screen.topQueries)) {
1092 if (entry.whenDenied == "hide") {
1093 continue;
1094 }
1095 entry.available = false;
1096 entry.reason = "denied";
1097 }
1098 if (entry.available || entry.isPublic) {
1099 fetchable.modelTypes.insert(screen.modelTypes.begin(), screen.modelTypes.end());
1100 for (ActionRef const& ref : screen.actions) {
1101 fetchable.actions.insert(ref.action);
1102 }
1103 }
1104 reply.entries.push_back(std::move(entry));
1105 }
1106 return fetchable;
1107 }
1108
1109 /// @brief Answers a `ui-fetch` and logs the outcome at debug.
1110 /// @param request The envelope; its body is a `FetchRequest`.
1111 /// @param authorizer The server's authoriser.
1112 /// @return The encoded reply.
1113 [[nodiscard]] std::string fetch(::morph::wire::Envelope const& request,
1114 ::morph::session::IAuthorizer const& authorizer) const {
1115 Answer answer = fetchAnswer(request, authorizer);
1116 ::morph::log::logDebug("[ui-fetch] {}: {}", answer.subject, answer.outcome);
1117 return std::move(answer.encoded);
1118 }
1119
1120 /// @brief Answers a `ui-fetch` without logging it.
1121 /// @param request The envelope; its body is a `FetchRequest`.
1122 /// @param authorizer The server's authoriser.
1123 /// @return The reply and its log fields.
1124 [[nodiscard]] Answer fetchAnswer(::morph::wire::Envelope const& request,
1125 ::morph::session::IAuthorizer const& authorizer) const {
1126 auto wanted = parseFetchRequest(request.body);
1127 if (!wanted) {
1128 std::string const reason = std::move(wanted.error());
1129 return Answer{.encoded = err(request, reason), .subject = "<unreadable>", .outcome = "refused: " + reason};
1130 }
1131 std::string const& hash = wanted->hash;
1132 auto const found = _bundle->files.find(hash);
1133 bool const isPublic = found != _bundle->files.end() &&
1134 std::ranges::any_of(found->second.holders,
1135 [](ServedFile::Holder const& holder) { return holder.isPublic; });
1136 if (isPublic) {
1137 return gate(authorizer, request, kHelloAction)
1138 ? Answer{.encoded = ok(request, found->second.bytes), .subject = hash, .outcome = "ok"}
1139 : Answer{.encoded = err(request, "unauthorized"),
1140 .subject = hash,
1141 .outcome = "refused: unauthorized"};
1142 }
1143 // The fetch gate comes before the lookup's answer, so a caller it refuses cannot learn which hashes exist.
1144 if (!gate(authorizer, request, kFetchAction)) {
1145 return Answer{
1146 .encoded = err(request, "unauthorized"), .subject = hash, .outcome = "refused: unauthorized"};
1147 }
1148 if (found == _bundle->files.end()) {
1149 return Answer{.encoded = err(request, "not found"), .subject = hash, .outcome = "refused: not found"};
1150 }
1151 bool const admitted = std::ranges::any_of(found->second.holders, [&](ServedFile::Holder const& holder) {
1152 return !holder.screen || screenAdmits(_bundle->screens.at(*holder.screen), request.session, authorizer);
1153 });
1154 return admitted
1155 ? Answer{.encoded = ok(request, found->second.bytes), .subject = hash, .outcome = "ok"}
1156 : Answer{
1157 .encoded = err(request, "unauthorized"), .subject = hash, .outcome = "refused: unauthorized"};
1158 }
1159
1160 /// @brief Whether the principal may fetch @p screen's document: it is available, and the catalog lists it as
1161 /// such (every top-level query permitted).
1162 [[nodiscard]] static bool screenAdmits(ServedScreen const& screen, ::morph::session::Context const& session,
1163 ::morph::session::IAuthorizer const& authorizer) {
1164 return screen.entry.available && detail::permitsAll(authorizer, session, screen.topQueries);
1165 }
1166
1167 std::shared_ptr<ServedBundle const> _bundle;
1168 ::morph::model::detail::ActionDispatcher* _dispatcher;
1169 std::shared_ptr<IFileStore> _files;
1170};
1171
1172} // namespace morph::ui
Handle< T > ref(std::string const &path)
A named read, {"ref": "name.path"}: a param, state, let, query value, element value or bound name.
Definition author.hpp:1137
Screen screen(Screen::Builder builder)
A screen with its id as a template argument, as spec 5 §11 writes it.
Definition author.hpp:4556
Command all(Commands const &steps)
all: steps started together.
Definition author.hpp:2928
Command upload(AnyHandle const &file, MutationRef const &mutation, UploadOptions const &options={})
upload: sends a file through a mutation (files/1).
Definition author.hpp:2975
What the UI service's client and server share (spec 5 §12): content hashes, the bundle's manifest,...
constexpr std::string_view kHelloKind
The envelope kind of the handshake.
Definition bundle.hpp:39
constexpr std::string_view kFetchKind
The envelope kind that fetches a file by its hash.
Definition bundle.hpp:43
constexpr std::string_view kCatalogKind
The envelope kind of the catalog.
Definition bundle.hpp:41
constexpr std::string_view kScreenFile
The file kinds of a manifest.
Definition bundle.hpp:82
constexpr std::string_view kFileKind
The envelope kind of the file side channel (files/1, spec 6 §18): uploads and downloads in chunks.
Definition bundle.hpp:47
constexpr std::string_view kIconFile
An icon of a theme's icon set: SVG text.
Definition bundle.hpp:92
std::optional< std::string > checkName(std::string_view name, std::string_view what)
Checks a name's length.
Definition bundle.hpp:471
std::expected< HelloRequest, std::string > parseHelloRequest(std::string_view text)
Reads a ui-hello request body.
Definition bundle.hpp:574
constexpr std::string_view kExprVocabulary
The expression vocabulary every bundle needs alongside ui/1.
Definition bundle.hpp:36
std::expected< FileRequest, std::string > parseFileRequest(std::string_view text)
Reads a ui-file request body.
Definition bundle.hpp:654
std::string manifestText(Manifest const &manifest)
The manifest's text: the JSON its digest is the hash of. Equal manifests write equal text.
Definition bundle.hpp:523
constexpr std::size_t kMaxFileBytes
The largest file a bundle may hold, and a client accepts.
Definition bundle.hpp:71
std::expected< FetchRequest, std::string > parseFetchRequest(std::string_view text)
Reads a ui-fetch request body.
Definition bundle.hpp:714
constexpr std::string_view kEvaluateKind
The envelope kind that evaluates an action body without running it (spec 5 §7).
Definition bundle.hpp:45
std::string contentHash(std::string_view bytes)
The content hash of bytes: sha256: and the SHA-256 digest in 64 lower-case hexadecimal digits.
Definition bundle.hpp:99
Message catalogues (spec 6 §19): a bundle's messages files read into a TranslationProvider that t res...
std::optional< std::string > catalogueLocale(std::string_view fileId)
Reads the locale from a catalogue's id.
Definition catalogue.hpp:50
std::expected< Catalogue, std::string > parseCatalogue(std::string_view text)
Parses a catalogue: a flat JSON object of message key to MessageFormat 2 pattern.
Definition catalogue.hpp:74
Envelope kinds a RemoteServer answers through an extension, such as the UI service's ui-* kinds.
Definition remote.hpp:231
std::vector< HeadAction > actions() const
The head's actions that are visible now, in order, each with whether it is enabled....
std::expected< std::shared_ptr< ServedBundle const >, Refusal > build() const
Builds the bundle.
Definition service.hpp:367
std::expected< void, Refusal > addFile(BundleFile file)
Adds a file that is not a screen: the app document (app, whose id is app), a theme (theme),...
Definition service.hpp:343
UiRegistry(ApplicationInfo application, Catalog catalog={})
An empty registry.
Definition service.hpp:292
void requireVocabulary(std::string vocabulary)
Adds a vocabulary the bundle needs beyond ui/1 and expr/1, such as forms/1 or files/1....
Definition service.hpp:359
std::expected< void, Refusal > addEntry(CatalogEntry entry)
Lists an entry that has no document: a host screen (host), or one the application has not built (avai...
Definition service.hpp:322
std::expected< void, Refusal > addScreen(CatalogEntry entry, std::string text)
Registers a screen: its catalog entry and its document.
Definition service.hpp:304
std::string handle(::morph::wire::Envelope const &request, ::morph::session::IAuthorizer const &authorizer, ::morph::backend::ConnectionFacts &facts) override
Answers one request; a ui-hello that offers events and is answered ok sets facts.events.
Definition service.hpp:828
std::string handle(::morph::wire::Envelope const &request, ::morph::session::IAuthorizer const &authorizer) override
Answers one request with no connection behind it: a ui-hello offer of events is accepted in the reply...
Definition service.hpp:814
bool serves(std::string_view kind) const override
Whether kind is one of the service's kinds.
Definition service.hpp:804
UiService(std::shared_ptr< ServedBundle const > bundle, ::morph::model::detail::ActionDispatcher &dispatcher=::morph::model::detail::defaultDispatcher(), std::shared_ptr< IFileStore > files=nullptr)
Serves bundle, and evaluates through dispatcher.
Definition service.hpp:791
std::vector< ActionRef > actions() const
Every action named.
Definition service.hpp:185
std::vector< ActionRef > const & topQueries() const noexcept
The top-level queries.
Definition service.hpp:181
std::vector< std::string > modelTypes() const
Every model type named.
Definition service.hpp:189
DocumentActions(Document const &document)
Walks document.
Definition service.hpp:172
The UI document (spec 5 §3): its model — declarations, commands, view nodes, components and customiza...
constexpr std::string_view kVocabulary
The vocabulary a document declares; the only one this client reads.
Definition document.hpp:37
std::expected< Document, Refusal > loadDocumentText(std::string_view text, LoadOptions const &options={})
Parses JSON text as a UI document and runs its load-time validation.
Value evaluate(Expr const &expr, Environment const &env)
Evaluates an expression.
The file side channel's store (files/1, spec 6 §18): uploads in chunks, and files a principal may rea...
constexpr std::string_view fileErrorText(FileError error) noexcept
The text of a FileError, as a ui-file refusal carries it.
FileError
Why a store refused a call.
bool permitsAll(::morph::session::IAuthorizer const &authorizer, ::morph::session::Context const &session, std::vector< ActionRef > const &refs)
Whether the principal of session may run every action of refs.
Definition service.hpp:260
std::optional< std::string > checkServable(std::string const &bytes)
Whether bytes can be served: within kMaxFileBytes, and within the envelope bound once escaped into an...
Definition service.hpp:135
constexpr bool isUiServiceRead(std::string_view modelType, std::string_view actionType) noexcept
Whether an authoriser is asked about one of the UI service's reads: ui's hello, catalog or fetch.
Definition service.hpp:763
std::optional< std::string > checkEntry(CatalogEntry const &entry)
Checks a catalog entry's own fields.
Definition service.hpp:148
void request(Store< ViewState, Msg > &store, bridge::BridgeHandler< Model, Sharing > &handler, async::CallbackScope const &scope, Action action, ToMsg toMsg, ToFailMsg toFailMsg)
Executes action and sends its result, or its failure, to store as one message.
Definition store.hpp:202
What a RemoteServer knows about one connection that a service, not core, decides: handed to IEnvelope...
Definition remote.hpp:216
bool events
Whether the connection negotiated server-sent events: its ui-hello offered them and the UI service ac...
Definition remote.hpp:219
Per-call session context carried through the bridge to the model.
Definition session.hpp:26
Authorizes incoming actions on a RemoteServer.
Definition session.hpp:137
virtual bool authorize(const Context &ctx, std::string_view modelType, std::string_view actionType) const =0
Returns true if ctx is allowed to invoke actionType on modelType.
What the catalog says about an action.
Definition document.hpp:78
An action a document names, with the model type it runs on: what the authoriser is asked about.
Definition service.hpp:49
std::string action
The action id.
Definition service.hpp:53
auto operator<=>(ActionRef const &) const =default
Orders by model type, then action.
std::string modelType
The model's registered type id.
Definition service.hpp:51
The application a bundle belongs to, as ui-hello names it.
Definition service.hpp:41
std::string version
The application's version.
Definition service.hpp:45
std::string id
The application's id.
Definition service.hpp:43
A file of the bundle that is not a screen, as UiRegistry::addFile takes it.
Definition service.hpp:269
std::string id
The file's id: app for the app document, or a theme's, a catalogue's or a component's name.
Definition service.hpp:273
bool isPublic
Whether anyone the ui-hello gate admits may fetch it, before sign-in.
Definition service.hpp:277
std::string bytes
The file, served byte for byte.
Definition service.hpp:275
std::string kind
app, theme, messages, component or icon.
Definition service.hpp:271
One catalog entry (spec 6 §8).
Definition bundle.hpp:251
std::optional< std::string > host
The host screen the application registered with the client instead of a document.
Definition bundle.hpp:273
bool isPublic
Whether the client may fetch the screen before sign-in.
Definition bundle.hpp:271
std::string whenDenied
What a permission refusal does to the entry: hide or disable.
Definition bundle.hpp:269
std::string id
The screen's id.
Definition bundle.hpp:253
The facts load-time validation needs from the application's catalog. Local mode answers them from the...
Definition document.hpp:105
A loaded and validated UI document: a screen, or the app document (id app, spec 6 §5).
Definition document.hpp:536
DocNodePtr view
The view; nullptr for the app document.
Definition document.hpp:570
ScopeDecl scope
The screen scope's declarations.
Definition document.hpp:556
A model alias.
Definition document.hpp:155
What the catalog says about a model type.
Definition document.hpp:40
A query (spec 5 §5).
Definition document.hpp:193
std::string model
The model alias it goes through.
Definition document.hpp:199
std::string action
The action id it runs, or evaluates.
Definition document.hpp:201
Why a document, an expression or a value was refused: where, and the reason.
Definition value.hpp:38
The declarations of one scope, in declaration order.
Definition document.hpp:367
std::vector< ModelDecl > models
Model aliases.
Definition document.hpp:369
std::vector< MutationDecl > mutations
Mutations.
Definition document.hpp:377
std::vector< QueryDecl > queries
Queries.
Definition document.hpp:375
An application's bundle, built and immutable: what UiService serves.
Definition service.hpp:96
std::string manifest
The manifest's text.
Definition service.hpp:102
std::vector< ActionRef > allActions
Every action any served document names, sorted and unique: the candidates of permitted.
Definition service.hpp:114
std::map< std::string, ServedFile, std::less<> > files
Every file by its content hash, the manifest included.
Definition service.hpp:106
std::vector< std::string > vocabularies
The vocabularies the bundle needs, sorted; always expr/1 and ui/1.
Definition service.hpp:100
std::string digest
The manifest's content hash, which ui-hello replies with.
Definition service.hpp:104
std::optional< std::map< std::string, std::string, std::less<> > > views
The view documents the registry's catalog gave, by the action of their v-query, for every action a do...
Definition service.hpp:123
std::vector< std::string > appModelTypes
The model types the app document names.
Definition service.hpp:112
std::vector< ServedScreen > screens
The catalog's screens, in registration order.
Definition service.hpp:108
std::optional< std::map< std::string, ActionInfo, std::less<> > > actions
The action facts the registry's catalog gave, for every action a document names; absent when it has n...
Definition service.hpp:120
std::optional< std::map< std::string, std::string, std::less<> > > wizards
The wizard documents the registry's catalog gave, by the action of their first step,...
Definition service.hpp:126
ApplicationInfo application
The application.
Definition service.hpp:98
std::optional< std::map< std::string, ModelInfo, std::less<> > > models
The model facts the registry's catalog gave, for every model type a document names; absent when it ha...
Definition service.hpp:117
std::vector< ActionRef > appActions
The actions the app document names.
Definition service.hpp:110
One manifest entry that names the file.
Definition service.hpp:63
bool isPublic
Whether the entry is public.
Definition service.hpp:69
std::optional< std::size_t > screen
For a screen, its index in ServedBundle::screens.
Definition service.hpp:71
std::string id
The file id.
Definition service.hpp:67
std::string kind
The file kind, or manifest for the manifest itself.
Definition service.hpp:65
One file a bundle serves, with every manifest entry whose bytes it is.
Definition service.hpp:61
std::vector< Holder > holders
The entries that name these bytes; two entries with equal bytes share one file.
Definition service.hpp:77
std::string bytes
The file's bytes.
Definition service.hpp:75
A screen as the bundle serves it.
Definition service.hpp:81
std::vector< std::string > modelTypes
Every model type an alias names, at any scope.
Definition service.hpp:92
std::string hash
The document's content hash; empty for an entry without a document.
Definition service.hpp:85
CatalogEntry entry
The catalog entry, as registered.
Definition service.hpp:83
std::vector< ActionRef > actions
Every action a query, a mutation or a model-bound node names, at any scope.
Definition service.hpp:90
std::vector< ActionRef > topQueries
The queries the document declares at its top level: the principal may see the screen when it may run ...
Definition service.hpp:88
JSON wire envelope used between any client and RemoteServer.
Definition wire.hpp:91
Theme tokens (ui-theme/1, spec 6 §6): DTCG 2025.10 token files read and checked, layered into a varia...
std::optional< std::string > checkTheme(ThemeIndex const &index, TokenFileLookup const &files)
Checks that every combination of a theme resolves: each variant (or none), density (or none) and surf...
Definition theme.hpp:910
bool isThemeIndex(std::string_view text)
Whether a theme file is a theme index rather than a token file: its top level has a string vocab.
Definition theme.hpp:739
std::expected< TokenSet, std::string > parseTokens(std::string_view text)
Parses a DTCG 2025.10 token file.
Definition theme.hpp:758
std::function< TokenSet const *(std::string_view fileId)> TokenFileLookup
Finds a token file of the bundle by id.
Definition theme.hpp:852
std::expected< ThemeIndex, std::string > parseThemeIndex(std::string_view text)
Parses a theme index: {"vocab": "ui-theme/1", "base": id, "dark": id, …, "density": {....
Definition theme.hpp:799
The UI document's typed values (ui::Value), its types (ui::Type), their JSON form,...
std::string pointerAppend(std::string_view base, std::string_view token)
Appends one reference token to a JSON Pointer, escaping ~ and / as RFC 6901 requires.
Definition value.hpp:68
Node text(Text spec)
Static or bound text.
Definition view.hpp:1194