20#include "../core/logger.hpp"
21#include "../core/remote.hpp"
22#include "../core/wire.hpp"
23#include "../session/session.hpp"
106 std::map<std::string, ServedFile, std::less<>>
files;
117 std::optional<std::map<std::string, ModelInfo, std::less<>>>
models;
120 std::optional<std::map<std::string, ActionInfo, std::less<>>>
actions;
123 std::optional<std::map<std::string, std::string, std::less<>>>
views;
126 std::optional<std::map<std::string, std::string, std::less<>>>
wizards;
135[[nodiscard]]
inline std::optional<std::string>
checkServable(std::string
const& bytes) {
137 return "the file is larger than " + std::to_string(
kMaxFileBytes) +
" bytes";
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"};
149 if (entry.
id.empty()) {
150 return std::string{
"a catalog entry has an id"};
152 if (
auto reason =
checkName(entry.
id,
"a catalog entry id")) {
156 return "`whenDenied` is `hide` or `disable`, not `" + entry.
whenDenied +
"`";
173 enter(document.
scope,
true);
175 node(*document.
view);
181 [[nodiscard]] std::vector<ActionRef>
const&
topQueries() const noexcept {
return _top; }
185 [[nodiscard]] std::vector<ActionRef>
actions()
const {
return {_all.begin(), _all.end()}; }
189 [[nodiscard]] std::vector<std::string>
modelTypes()
const {
return {_models.begin(), _models.end()}; }
192 void enter(
ScopeDecl const& scope,
bool top) {
194 _types.try_emplace(model.name).first->second.push_back(model.type);
195 _models.insert(model.type);
202 _all.insert(std::move(ref));
204 for (MutationDecl
const& mutation : scope.
mutations) {
205 _all.insert(ActionRef{.modelType = resolve(mutation.model), .action = mutation.action});
209 void leave(ScopeDecl
const& scope) {
210 for (ModelDecl
const& model : scope.models) {
211 _types.at(model.name).pop_back();
215 void node(DocNode
const& view) {
217 enter(*view.scope,
false);
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")}});
222 for (
auto const& [key, children] : view.children) {
223 static_cast<void>(key);
224 for (DocNodePtr
const& child : children) {
228 for (
auto const& [key, parts] : view.parts) {
229 static_cast<void>(key);
230 for (DocNodePtr
const& part : parts) {
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);
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;
262 return std::ranges::all_of(
263 refs, [&](
ActionRef const& ref) {
return authorizer.
authorize(session, ref.modelType, ref.action); });
293 : _application{std::move(application)}, _catalog{std::move(catalog)} {}
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));
309 ::morph::log::logDebug(
"[ui-serve] added the screen `{}` ({} bytes)", screenId, size);
311 ::morph::log::logWarn(
"[ui-serve] refused a screen: {}", added.error().message());
323 std::string
const entryId = entry.
id;
324 auto added = addEntryImpl(std::move(entry));
326 ::morph::log::logDebug(
"[ui-serve] added the entry `{}`", entryId);
328 ::morph::log::logWarn(
"[ui-serve] refused an entry: {}", added.error().message());
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));
349 ::morph::log::logDebug(
"[ui-serve] added the {} `{}` ({} bytes)", kind, fileId, size);
351 ::morph::log::logWarn(
"[ui-serve] refused a file: {}", added.error().message());
367 [[nodiscard]] std::expected<std::shared_ptr<ServedBundle const>,
Refusal>
build()
const {
368 auto built = buildImpl();
370 ::morph::log::logDebug(
"[ui-serve] built the bundle of `{}`: {} files, digest {}", _application.id,
371 (*built)->files.size(), (*built)->digest);
373 ::morph::log::logWarn(
"[ui-serve] refused to build: {}", built.error().message());
383 std::expected<void, Refusal> addScreenImpl(
CatalogEntry entry, std::string
text) {
384 if (
auto refusal = checkNewEntry(entry)) {
385 return std::unexpected(std::move(*refusal));
388 return refuse(entry.
id,
"a host screen has no document; it is added with addEntry");
390 if (
auto reason = detail::checkServable(text)) {
391 return refuse(entry.
id, std::move(*reason));
395 return std::unexpected(std::move(loaded.error()));
397 if (loaded->id != entry.
id) {
398 return refuse(entry.
id,
"the entry's id is not the document's id `" + loaded->id +
"`");
401 return refuse(entry.
id,
"the app document is added with addFile");
403 logWarnings(*loaded);
404 detail::DocumentActions
const named{*loaded};
405 Screen
screen{.served = ServedScreen{.entry = std::move(entry),
407 .topQueries = named.topQueries(),
408 .actions = named.actions(),
409 .modelTypes = named.modelTypes()},
410 .text = std::move(text),
412 for (
auto const& [name, component] : loaded->components) {
413 static_cast<void>(name);
414 if (!component.qml.empty()) {
418 _screens.push_back(std::move(screen));
425 std::expected<void, Refusal> addEntryImpl(CatalogEntry entry) {
426 if (
auto refusal = checkNewEntry(entry)) {
427 return std::unexpected(std::move(*refusal));
429 if (!entry.host && entry.available) {
430 return refuse(entry.id,
"an entry without a document is a host screen or unavailable");
432 _screens.push_back(Screen{
434 ServedScreen{.entry = std::move(entry), .hash = {}, .topQueries = {}, .actions = {}, .modelTypes = {}},
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, "
451 if (file.id.empty()) {
452 return refuse(file.id,
"a file has an id");
454 if (
auto reason = detail::checkName(file.id,
"a file id")) {
455 return refuse(file.id, std::move(*reason));
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");
461 if (
auto reason = detail::checkServable(file.bytes)) {
462 return refuse(file.id, std::move(*reason));
464 if (
auto added = addKind(file); !added) {
467 _files.push_back(std::move(file));
473 [[nodiscard]] std::expected<std::shared_ptr<ServedBundle const>, Refusal> buildImpl()
const {
474 if (
auto refusal = checkBuildable()) {
475 return std::unexpected(std::move(*refusal));
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;
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;
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)};
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; });
503 return Refusal{.document = indexId,
505 .reason = std::format(
"the theme index names the icon `{}` as `{}`, which is not "
506 "an icon file of the bundle",
521 std::vector<std::pair<std::string, std::string>> components;
525 std::expected<void, Refusal> addKind(BundleFile
const& file) {
526 if (file.kind == kMessagesFile) {
527 return checkCatalogue(file);
529 if (file.kind == kThemeFile) {
530 return addTheme(file);
532 if (file.kind == kAppFile) {
535 if (file.kind == kIconFile) {
536 return checkIcon(file);
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,
547 if (!file.bytes.contains(
"<svg")) {
548 return refuse(file.id,
"an icon is SVG text, and this one has no <svg> element");
553 [[nodiscard]]
static std::expected<void, Refusal> checkCatalogue(BundleFile
const& file) {
555 return refuse(file.id,
"a message catalogue's id is i18n/<locale>.json");
559 return refuse(file.id, std::move(catalogue.error()));
561 if (!catalogue->dropped.empty()) {
562 return refuse(file.id,
"a message is outside the ui/1 subset: " + catalogue->dropped.front());
567 std::expected<void, Refusal> addTheme(BundleFile
const& file) {
571 return refuse(file.id,
"the theme index: " + index.error());
573 _themeIndexes.emplace_back(file.id, std::move(*index));
578 return refuse(file.id,
"the token file: " + tokens.error());
580 _tokenFiles.emplace(file.id, std::move(*tokens));
584 std::expected<void, Refusal> addApp(BundleFile
const& file) {
585 auto loaded =
loadDocumentText(file.bytes, LoadOptions{.catalog = _catalog});
587 return std::unexpected(std::move(loaded.error()));
589 if (loaded->id != kAppFile) {
590 return refuse(file.id,
"the app document's id is `app`");
592 logWarnings(*loaded);
593 detail::DocumentActions
const named{*loaded};
594 _appActions = named.actions();
595 _appModelTypes = named.modelTypes();
601 static void logWarnings(Document
const& document) {
602 for (LoadWarning
const& warning : document.warnings) {
603 morph::log::logWarn(warning.message(document.id));
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)});
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)};
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"};
632 [[nodiscard]] std::optional<Refusal> checkBuildable()
const {
633 if (
auto refusal = checkThemes()) {
636 std::set<std::string, std::less<>> componentHashes;
637 for (BundleFile
const& file : _files) {
638 if (file.kind == kComponentFile) {
642 for (Screen
const& screen : _screens) {
643 for (
auto const& [path, hash] :
screen.components) {
644 if (!componentHashes.contains(hash)) {
647 .reason =
"the delivered component is not in the bundle"};
651 if (_screens.size() > kMaxBundleFiles || _screens.size() + _files.size() > kMaxBundleFiles) {
655 .reason =
"the bundle holds more than " + std::to_string(kMaxBundleFiles) +
" files or entries"};
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)};
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) {
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});
677 for (Screen
const& screen : _screens) {
683 bundle.screens.size());
685 bundle.screens.push_back(
screen.served);
687 for (BundleFile
const& file : _files) {
688 serve(file, std::nullopt);
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);
695 bundle.files.insert_or_assign(
697 ServedFile{.bytes = bundle.manifest,
698 .holders = {ServedFile::Holder{
699 .kind =
"manifest", .id = _application.id, .isPublic = true, .screen = std::nullopt}}});
705 std::set<ActionRef>
all{_appActions.begin(), _appActions.end()};
706 std::set<std::string, std::less<>> modelTypes{_appModelTypes.begin(), _appModelTypes.end()};
709 modelTypes.insert(
screen.modelTypes.begin(),
screen.modelTypes.end());
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) {
716 known.emplace(type, _catalog.model(type).value_or(
ModelInfo{}));
719 if (_catalog.action) {
720 auto& known = bundle.actions.emplace();
723 known.emplace(
ref.action, _catalog.action(
ref.action).value_or(
ActionInfo{}));
726 auto const documents = [&
all](
auto const& lookup,
auto& out) {
730 auto& known = out.emplace();
732 if (std::optional<std::string> document = lookup(
ref.action)) {
733 known.emplace(
ref.action, std::move(*document));
737 documents(_catalog.view, bundle.views);
738 documents(_catalog.wizard, bundle.wizards);
743 std::vector<Screen> _screens;
744 std::vector<BundleFile> _files;
746 std::vector<std::pair<std::string, ThemeIndex>> _themeIndexes;
748 std::map<std::string, TokenSet, std::less<>> _tokenFiles;
749 std::vector<ActionRef> _appActions;
750 std::vector<std::string> _appModelTypes;
763[[nodiscard]]
constexpr bool isUiServiceRead(std::string_view modelType, std::string_view actionType)
noexcept {
764 return modelType ==
"ui" && (actionType ==
"hello" || actionType ==
"catalog" || actionType ==
"fetch");
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)} {
797 throw std::invalid_argument(
"UiService: the bundle is null");
804 [[nodiscard]]
bool serves(std::string_view kind)
const override {
806 (kind ==
kFileKind && _files !=
nullptr);
817 return handle(request, authorizer, facts);
832 return hello(request, authorizer, facts);
835 return catalog(request, authorizer);
838 return evaluate(request, authorizer);
841 return file(request, authorizer);
843 return fetch(request, authorizer);
849 std::set<std::string, std::less<>> modelTypes;
850 std::set<std::string, std::less<>> actions;
854 return ::morph::wire::encode(::morph::wire::makeOk(request.callId, std::move(body)));
858 return ::morph::wire::encode(::morph::wire::makeErr(std::move(message), request.callId));
871 [[nodiscard]]
static bool gate(::morph::session::IAuthorizer
const& authorizer,
872 ::morph::wire::Envelope
const& request, std::string_view action) {
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);
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)) {
900 .encoded = err(request,
"unauthorized"), .subject = application, .outcome =
"refused: unauthorized"};
904 std::string
const reason = std::move(client.error());
905 return Answer{.encoded = err(request, reason), .subject = application, .outcome =
"refused: " + reason};
908 for (std::string
const& vocabulary : _bundle->vocabularies) {
909 if (std::ranges::find(client->vocabularies, vocabulary) == client->vocabularies.end()) {
910 missing += (missing.empty() ?
"" :
", ") + vocabulary;
913 if (!missing.empty()) {
914 std::string
const reason =
"missing vocabulary: " + missing;
915 return Answer{.encoded = err(request, reason), .subject = application, .outcome =
"refused: " + reason};
917 if (client->events) {
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};
934 [[nodiscard]] std::string
evaluate(::morph::wire::Envelope
const& request,
935 ::morph::session::IAuthorizer
const& authorizer)
const {
937 return err(request,
"ui-evaluate names no modelType and actionType");
940 return err(request,
"unauthorized");
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));
947 return ok(request, detail::writeMessage(reply));
958 [[nodiscard]] std::string file(::morph::wire::Envelope
const& request,
959 ::morph::session::IAuthorizer
const& authorizer)
const {
962 return err(request, std::move(parsed.error()));
964 FileRequest& step = *parsed;
965 std::string
const& principal =
request.session.principal;
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");
973 return err(request,
"unauthorized");
975 auto upload = _files->begin(
976 principal, FileInfo{.name = std::move(step.name), .type = std::move(step.type), .size = step.size});
978 return refused(
upload.error());
980 reply.upload = std::move(*upload);
981 }
else if (step.op ==
"chunk") {
982 auto bytes = ::morph::session::detail::base64UrlDecode(step.data);
984 return err(request,
"ui-file chunk data is not base64url");
986 if (bytes->size() > kMaxFileChunkBytes) {
987 return err(request,
"ui-file chunk is longer than " + std::to_string(kMaxFileChunkBytes) +
" bytes");
989 auto received = _files->append(principal, step.upload, step.offset, *bytes);
991 return refused(received.error());
993 reply.received = *received;
994 }
else if (step.op ==
"end") {
995 auto ref = _files->finish(principal, step.upload);
997 return refused(
ref.error());
999 reply.ref = std::move(*ref);
1001 auto slice = _files->read(principal, step.ref, step.offset, std::min(step.length, kMaxFileChunkBytes));
1003 return refused(slice.error());
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);
1010 return ok(request, detail::writeMessage(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);
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)) {
1033 .encoded = err(request,
"unauthorized"), .subject = application, .outcome =
"refused: unauthorized"};
1036 Fetchable
const fetchable = listEntries(reply,
request.session, authorizer);
1037 for (ActionRef
const& ref : _bundle->allActions) {
1039 reply.permitted.push_back(
ref.action);
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);
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);
1059 auto const documents = [&fetchable](
auto const& served,
auto& out) {
1063 auto& known = out.emplace();
1064 for (
auto const& [action, document] : *served) {
1065 if (fetchable.actions.contains(action)) {
1066 known.emplace(action, document);
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())};
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()},
1086 for (ActionRef
const& ref : _bundle->appActions) {
1087 fetchable.actions.insert(
ref.action);
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") {
1095 entry.available =
false;
1096 entry.reason =
"denied";
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);
1104 reply.entries.push_back(std::move(entry));
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);
1124 [[nodiscard]] Answer fetchAnswer(::morph::wire::Envelope
const& request,
1125 ::morph::session::IAuthorizer
const& authorizer)
const {
1128 std::string
const reason = std::move(wanted.error());
1129 return Answer{.encoded = err(request, reason), .subject =
"<unreadable>", .outcome =
"refused: " + reason};
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; });
1137 return gate(authorizer, request, kHelloAction)
1138 ? Answer{.encoded = ok(request, found->second.bytes), .subject = hash, .outcome =
"ok"}
1139 : Answer{.encoded = err(request,
"unauthorized"),
1141 .outcome =
"refused: unauthorized"};
1144 if (!gate(authorizer, request, kFetchAction)) {
1146 .encoded = err(request,
"unauthorized"), .subject = hash, .outcome =
"refused: unauthorized"};
1148 if (found == _bundle->files.end()) {
1149 return Answer{.encoded = err(request,
"not found"), .subject = hash, .outcome =
"refused: not found"};
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);
1155 ? Answer{.encoded = ok(request, found->second.bytes), .subject = hash, .outcome =
"ok"}
1157 .encoded = err(request,
"unauthorized"), .subject = hash, .outcome =
"refused: unauthorized"};
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);
1167 std::shared_ptr<ServedBundle const> _bundle;
1168 ::morph::model::detail::ActionDispatcher* _dispatcher;
1169 std::shared_ptr<IFileStore> _files;
Handle< T > ref(std::string const &path)
A named read, {"ref": "name.path"}: a param, state, let, query value, element value or bound name.
Screen screen(Screen::Builder builder)
A screen with its id as a template argument, as spec 5 §11 writes it.
Command all(Commands const &steps)
all: steps started together.
Command upload(AnyHandle const &file, MutationRef const &mutation, UploadOptions const &options={})
upload: sends a file through a mutation (files/1).
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.
constexpr std::string_view kFetchKind
The envelope kind that fetches a file by its hash.
constexpr std::string_view kCatalogKind
The envelope kind of the catalog.
constexpr std::string_view kScreenFile
The file kinds of a manifest.
constexpr std::string_view kFileKind
The envelope kind of the file side channel (files/1, spec 6 §18): uploads and downloads in chunks.
constexpr std::string_view kIconFile
An icon of a theme's icon set: SVG text.
std::optional< std::string > checkName(std::string_view name, std::string_view what)
Checks a name's length.
std::expected< HelloRequest, std::string > parseHelloRequest(std::string_view text)
Reads a ui-hello request body.
constexpr std::string_view kExprVocabulary
The expression vocabulary every bundle needs alongside ui/1.
std::expected< FileRequest, std::string > parseFileRequest(std::string_view text)
Reads a ui-file request body.
std::string manifestText(Manifest const &manifest)
The manifest's text: the JSON its digest is the hash of. Equal manifests write equal text.
constexpr std::size_t kMaxFileBytes
The largest file a bundle may hold, and a client accepts.
std::expected< FetchRequest, std::string > parseFetchRequest(std::string_view text)
Reads a ui-fetch request body.
constexpr std::string_view kEvaluateKind
The envelope kind that evaluates an action body without running it (spec 5 §7).
std::string contentHash(std::string_view bytes)
The content hash of bytes: sha256: and the SHA-256 digest in 64 lower-case hexadecimal digits.
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.
std::expected< Catalogue, std::string > parseCatalogue(std::string_view text)
Parses a catalogue: a flat JSON object of message key to MessageFormat 2 pattern.
Envelope kinds a RemoteServer answers through an extension, such as the UI service's ui-* kinds.
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.
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),...
UiRegistry(ApplicationInfo application, Catalog catalog={})
An empty registry.
void requireVocabulary(std::string vocabulary)
Adds a vocabulary the bundle needs beyond ui/1 and expr/1, such as forms/1 or files/1....
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...
std::expected< void, Refusal > addScreen(CatalogEntry entry, std::string text)
Registers a screen: its catalog entry and its document.
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.
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...
bool serves(std::string_view kind) const override
Whether kind is one of the service's kinds.
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.
std::vector< ActionRef > actions() const
Every action named.
std::vector< ActionRef > const & topQueries() const noexcept
The top-level queries.
std::vector< std::string > modelTypes() const
Every model type named.
DocumentActions(Document const &document)
Walks document.
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.
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.
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...
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.
std::optional< std::string > checkEntry(CatalogEntry const &entry)
Checks a catalog entry's own fields.
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.
What a RemoteServer knows about one connection that a service, not core, decides: handed to IEnvelope...
bool events
Whether the connection negotiated server-sent events: its ui-hello offered them and the UI service ac...
Per-call session context carried through the bridge to the model.
Authorizes incoming actions on a RemoteServer.
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.
An action a document names, with the model type it runs on: what the authoriser is asked about.
std::string action
The action id.
auto operator<=>(ActionRef const &) const =default
Orders by model type, then action.
std::string modelType
The model's registered type id.
The application a bundle belongs to, as ui-hello names it.
std::string version
The application's version.
std::string id
The application's id.
A file of the bundle that is not a screen, as UiRegistry::addFile takes it.
std::string id
The file's id: app for the app document, or a theme's, a catalogue's or a component's name.
bool isPublic
Whether anyone the ui-hello gate admits may fetch it, before sign-in.
std::string bytes
The file, served byte for byte.
std::string kind
app, theme, messages, component or icon.
One catalog entry (spec 6 §8).
std::optional< std::string > host
The host screen the application registered with the client instead of a document.
bool isPublic
Whether the client may fetch the screen before sign-in.
std::string whenDenied
What a permission refusal does to the entry: hide or disable.
std::string id
The screen's id.
The facts load-time validation needs from the application's catalog. Local mode answers them from the...
A loaded and validated UI document: a screen, or the app document (id app, spec 6 §5).
DocNodePtr view
The view; nullptr for the app document.
ScopeDecl scope
The screen scope's declarations.
What the catalog says about a model type.
std::string model
The model alias it goes through.
std::string action
The action id it runs, or evaluates.
Why a document, an expression or a value was refused: where, and the reason.
The declarations of one scope, in declaration order.
std::vector< ModelDecl > models
Model aliases.
std::vector< MutationDecl > mutations
Mutations.
std::vector< QueryDecl > queries
Queries.
An application's bundle, built and immutable: what UiService serves.
std::string manifest
The manifest's text.
std::vector< ActionRef > allActions
Every action any served document names, sorted and unique: the candidates of permitted.
std::map< std::string, ServedFile, std::less<> > files
Every file by its content hash, the manifest included.
std::vector< std::string > vocabularies
The vocabularies the bundle needs, sorted; always expr/1 and ui/1.
std::string digest
The manifest's content hash, which ui-hello replies with.
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...
std::vector< std::string > appModelTypes
The model types the app document names.
std::vector< ServedScreen > screens
The catalog's screens, in registration order.
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...
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,...
ApplicationInfo application
The application.
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...
std::vector< ActionRef > appActions
The actions the app document names.
One manifest entry that names the file.
bool isPublic
Whether the entry is public.
std::optional< std::size_t > screen
For a screen, its index in ServedBundle::screens.
std::string id
The file id.
std::string kind
The file kind, or manifest for the manifest itself.
One file a bundle serves, with every manifest entry whose bytes it is.
std::vector< Holder > holders
The entries that name these bytes; two entries with equal bytes share one file.
std::string bytes
The file's bytes.
A screen as the bundle serves it.
std::vector< std::string > modelTypes
Every model type an alias names, at any scope.
std::string hash
The document's content hash; empty for an entry without a document.
CatalogEntry entry
The catalog entry, as registered.
std::vector< ActionRef > actions
Every action a query, a mutation or a model-bound node names, at any scope.
std::vector< ActionRef > topQueries
The queries the document declares at its top level: the principal may see the screen when it may run ...
JSON wire envelope used between any client and RemoteServer.
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...
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.
std::expected< TokenSet, std::string > parseTokens(std::string_view text)
Parses a DTCG 2025.10 token file.
std::function< TokenSet const *(std::string_view fileId)> TokenFileLookup
Finds a token file of the bundle by id.
std::expected< ThemeIndex, std::string > parseThemeIndex(std::string_view text)
Parses a theme index: {"vocab": "ui-theme/1", "base": id, "dark": id, …, "density": {....
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.
Node text(Text spec)
Static or bound text.