@ti-engine/web-framework 1.20.1 → 1.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -14,8 +14,9 @@ const fs = require( "node:fs" );
14
14
  const configRegistry = require( "#config-registry" );
15
15
  const configService = require( "#config-service" );
16
16
  const authorization = require( "#authorization" );
17
+ const applicationInfo = require( "#application-info" );
17
18
 
18
- /** @import { TiSession } from "#definitions" */
19
+ /** @import { TiApplicationInfo, TiInfoSection, TiProfileInfo, TiSession } from "#definitions" */
19
20
 
20
21
  const RE_NONCE_ATTR = /\{ti-nonce-placeholder}/g;
21
22
  const RE_CSRF_ATTR = /\{ti-csrf-placeholder}/g;
@@ -124,6 +125,7 @@ class TiWebAppManager {
124
125
  #staticFileCache = {};
125
126
  #staticFileCacheEnabled;
126
127
  #enabledAuthMethods = [];
128
+ #baseApplicationInfo = null;
127
129
 
128
130
  /**
129
131
  * @constructor
@@ -165,6 +167,10 @@ class TiWebAppManager {
165
167
  title: "Profile",
166
168
  path: "fragments/frame-profile.html"
167
169
  };
170
+ this.#fragments[ 'about' ] = {
171
+ title: "About",
172
+ path: "fragments/frame-about.html"
173
+ };
168
174
  this.#fragments[ 'not-found' ] = {
169
175
  title: "Not Found",
170
176
  path: "fragments/frame-not-found.html"
@@ -393,13 +399,20 @@ class TiWebAppManager {
393
399
  * @public
394
400
  */
395
401
  processDataRequest( session, view, options = {} ) {
402
+ if ( view === "profile" ) {
403
+ return this.getProfileInfo( session );
404
+ }
405
+ if ( view === "about" ) {
406
+ return this.getApplicationInfo( session );
407
+ }
396
408
  return new Promise( ( resolve, reject ) => {
397
409
  if ( view === "config" ) {
398
410
  resolve( {
399
411
  labels: localization.getAllLabels( session?.language ),
400
412
  auth: {
401
413
  isAuthenticated: Boolean( session && session.user )
402
- }
414
+ },
415
+ componentsConfig: this.buildComponentsConfig( session )
403
416
  } );
404
417
  } else {
405
418
  reject( exceptions.raise( exceptions.exceptionCode.E_WEB_INVALID_REQUEST_URI, { view: view } ) );
@@ -407,6 +420,198 @@ class TiWebAppManager {
407
420
  } );
408
421
  }
409
422
 
423
+ /**
424
+ * Returns the configuration for the shared UI components the application shell renders — currently the sidebar
425
+ * user flyout menu. Shipped as part of the `config` data payload and merged into the `tiComponentsConfig`
426
+ * Alpine store on the client.
427
+ * <br/>
428
+ * The default menu links the two screens the framework itself provides (Profile and About) plus sign-out, so a
429
+ * consuming application gets a working user menu without configuring one. Override to replace it; a subclass
430
+ * that supplies its own `componentsConfig` naturally supersedes this.
431
+ *
432
+ * @method
433
+ * @param {TiSession} session
434
+ * @returns {Object}
435
+ * @virtual
436
+ * @public
437
+ */
438
+ buildComponentsConfig( session ) {
439
+ const language = session && session.language;
440
+ return {
441
+ userProfileMenu: {
442
+ menuTitle: localization.getLabel( "interface.topbar.user-profile", language, "Your profile" ),
443
+ placement: "right-end",
444
+ offset: 0,
445
+ buttonConfigs: [ {
446
+ title: localization.getLabel( "interface.user-menu.profile", language, "Your profile" ),
447
+ icon: "user-profile",
448
+ action: { href: "/app/profile", target: "#ti-content", swap: "innerHTML" }
449
+ }, {
450
+ title: localization.getLabel( "interface.user-menu.about", language, "About" ),
451
+ icon: "info-circle",
452
+ action: { href: "/app/about", target: "#ti-content", swap: "innerHTML" }
453
+ }, {
454
+ title: localization.getLabel( "interface.user-menu.logout", language, "Logout" ),
455
+ icon: "logout",
456
+ action: { href: "/logout", method: "post", target: "body", swap: "outerHTML" }
457
+ } ]
458
+ }
459
+ };
460
+ }
461
+
462
+ /**
463
+ * Returns the descriptor rendered by the "Profile" screen — the identity header plus an ordered list of titled
464
+ * label/value sections. Every string in it is display-ready: the server resolves labels and formats values,
465
+ * because this is where the session language and the label catalogue are (see {@link resolveLabel}).
466
+ * <br/>
467
+ * The default implementation reports what the framework itself knows about the session user — name, username,
468
+ * e-mail, language and roles. Override in subclasses to show application-owned data instead; the screen, its
469
+ * Alpine component and its styling are inherited unchanged, so an override only decides the content.
470
+ * <br/>
471
+ * NOTE: The descriptor is always about the SESSION user. There is deliberately no "whose profile" parameter —
472
+ * viewing another person's record belongs to an application screen that carries its own scoping rules.
473
+ *
474
+ * @method
475
+ * @param {TiSession} session
476
+ * @returns {Promise<TiProfileInfo>}
477
+ * @exception {TiException.E_SEC_UNAUTHORIZED_ACCESS} (401) When the session carries no user.
478
+ * @virtual
479
+ * @public
480
+ */
481
+ getProfileInfo( session ) {
482
+ return new Promise( ( resolve, reject ) => {
483
+ const user = session && session.user;
484
+ if ( !user ) {
485
+ return reject( exceptions.raise( exceptions.exceptionCode.E_SEC_UNAUTHORIZED_ACCESS, null, exceptions.httpCode.C_401 ) );
486
+ }
487
+ resolve( {
488
+ identity: this.buildSessionIdentity( session ),
489
+ sections: this.buildAccountSections( session )
490
+ } );
491
+ } );
492
+ }
493
+
494
+ /**
495
+ * Returns the descriptor rendered by the "About" screen — the application's own identity (name, version,
496
+ * release date, description, license, homepage) plus the ti-engine component versions it runs on, and any
497
+ * extra sections the application contributes.
498
+ * <br/>
499
+ * The baseline is resolved once from the consuming application's `package.json`, overridable through
500
+ * `TI_WEB_APP_NAME` / `TI_WEB_APP_VERSION` / `TI_WEB_APP_RELEASE_DATE` (see `#application-info`), and cached —
501
+ * the manifest cannot change while the process runs.
502
+ * <br/>
503
+ * NOTE: Runtime facts (node version, platform, instance identity) are attached only for an `admin` session.
504
+ * They are operational detail that helps support and means nothing to an ordinary user, so they are not handed
505
+ * to every signed-in visitor. Override in subclasses to append application-specific sections; call `super` and
506
+ * extend the result rather than rebuilding it.
507
+ *
508
+ * @method
509
+ * @param {TiSession} session
510
+ * @returns {Promise<TiApplicationInfo>}
511
+ * @virtual
512
+ * @public
513
+ */
514
+ getApplicationInfo( session ) {
515
+ return new Promise( ( resolve ) => {
516
+ if ( !this.#baseApplicationInfo ) {
517
+ this.#baseApplicationInfo = applicationInfo.buildApplicationInfo( {
518
+ manifest: applicationInfo.readApplicationManifest(),
519
+ env: process.env,
520
+ components: TiWebAppManager.resolveFrameworkComponents()
521
+ } );
522
+ }
523
+
524
+ const info = structuredClone( this.#baseApplicationInfo );
525
+ if ( authorization.hasAnyRole( session, [ authorization.ADMIN_ROLE ] ) ) {
526
+ info.runtime = {
527
+ node: process.version,
528
+ platform: `${ process.platform } · ${ process.arch }`,
529
+ application: this.#webAppIdentifier
530
+ };
531
+ }
532
+ resolve( info );
533
+ } );
534
+ }
535
+
536
+ /**
537
+ * Builds the identity header of the Profile screen from the session user. Kept separate from
538
+ * {@link TiWebAppManager#getProfileInfo} so a subclass that replaces the sections can still reuse — or fall
539
+ * back to — the framework's identity block when the application has no richer identity to show.
540
+ *
541
+ * @method
542
+ * @param {TiSession} session
543
+ * @returns {Object}
544
+ * @public
545
+ */
546
+ buildSessionIdentity( session ) {
547
+ const user = ( session && session.user ) || {};
548
+ return {
549
+ name: String( user.name || user.username || user.userID || "" ),
550
+ subtitle: "",
551
+ caption: String( user.email || "" ),
552
+ avatarSeed: String( user.userID || user.username || "" ),
553
+ // No pills by default: the framework knows roles only as opaque codes, and a stack of pills reading
554
+ // "1" / "2" beside the name is noise. The Access section lists them, and an application that has
555
+ // meaningful status to show (employment state, an ID, a badge) supplies its own.
556
+ tags: []
557
+ };
558
+ }
559
+
560
+ /**
561
+ * Builds the framework's account-level Profile sections from the session user. A subclass showing richer
562
+ * application data can append these so the account facts remain visible alongside it.
563
+ *
564
+ * @method
565
+ * @param {TiSession} session
566
+ * @returns {TiInfoSection[]}
567
+ * @public
568
+ */
569
+ buildAccountSections( session ) {
570
+ const user = ( session && session.user ) || {};
571
+ const language = session && session.language;
572
+ const roles = Array.isArray( user.roles ) ? user.roles : [];
573
+
574
+ return [ {
575
+ title: localization.getLabel( "interface.profile.section-account", language, "Account" ),
576
+ icon: "user",
577
+ items: [
578
+ { label: localization.getLabel( "interface.profile.field-name", language, "Full name" ), value: String( user.name || "" ) },
579
+ { label: localization.getLabel( "interface.profile.field-username", language, "Username" ), value: String( user.username || "" ) },
580
+ { label: localization.getLabel( "interface.profile.field-email", language, "E-mail" ), value: String( user.email || "" ), wide: true },
581
+ { label: localization.getLabel( "interface.profile.field-user-id", language, "User ID" ), value: String( user.userID || "" ), mono: true },
582
+ { label: localization.getLabel( "interface.profile.field-language", language, "Language" ), value: String( user.language || language || "" ) }
583
+ ]
584
+ }, {
585
+ title: localization.getLabel( "interface.profile.section-access", language, "Access" ),
586
+ icon: "check-circle",
587
+ items: [
588
+ { label: localization.getLabel( "interface.profile.field-roles", language, "Roles" ), value: roles.join( " · " ), wide: true }
589
+ ]
590
+ } ];
591
+ }
592
+
593
+ /**
594
+ * Resolves the versions of the ti-engine packages the running application is built on, for the About screen.
595
+ * <br/>
596
+ * NOTE: `@ti-engine/core` does not expose `./package.json` through its exports map, so its manifest is located
597
+ * by walking up from a module it *does* export. A package that cannot be resolved is simply omitted — an
598
+ * informational screen must never be the reason a request fails.
599
+ *
600
+ * @method
601
+ * @static
602
+ * @returns {Array<{name: string, version: string}>}
603
+ * @public
604
+ */
605
+ static resolveFrameworkComponents() {
606
+ const manifests = [
607
+ applicationInfo.readApplicationManifest( path.join( __dirname, ".." ) ),
608
+ TiWebAppManager.#readManifestForModule( "@ti-engine/core/tools" )
609
+ ];
610
+ return manifests
611
+ .filter( ( manifest ) => manifest && manifest.name && manifest.version )
612
+ .map( ( manifest ) => ( { name: manifest.name, version: manifest.version } ) );
613
+ }
614
+
410
615
  /**
411
616
  * Used to process an application service request.
412
617
  *
@@ -452,6 +657,34 @@ class TiWebAppManager {
452
657
 
453
658
  /* Private interface */
454
659
 
660
+ /**
661
+ * Locates the `package.json` owning a resolvable module specifier by walking up from the resolved file.
662
+ *
663
+ * @method
664
+ * @static
665
+ * @param {string} moduleSpecifier
666
+ * @returns {Object} The manifest, or an empty object when it cannot be located.
667
+ */
668
+ static #readManifestForModule( moduleSpecifier ) {
669
+ try {
670
+ let directory = path.dirname( require.resolve( moduleSpecifier ) );
671
+ for ( let depth = 0; depth < 8; depth++ ) {
672
+ const manifest = applicationInfo.readApplicationManifest( directory );
673
+ if ( manifest && manifest.name ) {
674
+ return manifest;
675
+ }
676
+ const parent = path.dirname( directory );
677
+ if ( parent === directory ) {
678
+ break;
679
+ }
680
+ directory = parent;
681
+ }
682
+ } catch {
683
+ // An unresolvable package simply does not appear in the component list.
684
+ }
685
+ return {};
686
+ }
687
+
455
688
  /**
456
689
  * Returns the HTML fragment for the requested route.
457
690
  *
package/bin/web-server.js CHANGED
@@ -60,12 +60,18 @@ const applyWebConfigEnvOverrides = require( "#web-config-env" );
60
60
  /**
61
61
  * @typedef {Object} SettingsAuth
62
62
  * @property {string[]} enabledMethods
63
- * @property {Object} local
63
+ * @property {SettingsAuthLocal} local
64
64
  * @property {Object} oauth2
65
65
  * @property {SettingsOAuth2Client} [oauth2.azure]
66
66
  * @property {SettingsOAuth2Client} [oauth2.google]
67
67
  */
68
68
 
69
+ /**
70
+ * @typedef {Object} SettingsAuthLocal
71
+ * @property {string} [usersPath] Path to the JSON file of local user records (see `TI_WEB_AUTH_LOCAL_USERS_PATH`).
72
+ * Local sign-in refuses everyone whenever this is absent, unreadable, or yields no usable records.
73
+ */
74
+
69
75
  /**
70
76
  * @typedef {Object} SettingsOAuth2Client
71
77
  * @property {string} [clientID]
@@ -430,6 +436,11 @@ class TiWebServer extends ServiceConsumer {
430
436
  * Hook for the application to augment the freshly-authenticated session (e.g. derive domain roles from an
431
437
  * identity store or the org chart). Runs synchronously, once per login, before the framework's additive `admin`
432
438
  * role is applied. The default is a no-op. Any test-user role injection is an override of whatever the app derives.
439
+ * <br/>
440
+ * **Refusing a login.** Throwing from this hook refuses the sign-in: the framework destroys the freshly regenerated
441
+ * session (so no usable session survives the refusal), the login handler raises `401`, and the error handler
442
+ * redirects the browser to the login page with the exception code in `?error=`. Throw when the authenticated
443
+ * identity cannot be mapped to an application principal; return the session unchanged to accept it.
433
444
  *
434
445
  * @method
435
446
  * @virtual
@@ -457,6 +468,12 @@ class TiWebServer extends ServiceConsumer {
457
468
 
458
469
  /**
459
470
  * Used to set up user authorization according to the specified auth method.
471
+ * <br/>
472
+ * NOTE: This presupposes a successful, immediately preceding {@link TiWebServer#authenticate} call for the same
473
+ * credentials — it is **not** an independent authentication check. For the `local` method it builds the session
474
+ * user from the directory record named by `oidc.username`, verifying that the record exists and is not disabled
475
+ * but performing no password comparison of its own; the framework's own login route calls `authenticate` first.
476
+ * Calling this directly without that preceding step would mint a session for any known username.
460
477
  *
461
478
  * @method
462
479
  * @param {TiAuthMethod} authMethod
@@ -18,6 +18,9 @@
18
18
  "openid-google"
19
19
  ],
20
20
  "admins": [],
21
+ "local": {
22
+ "usersPath": ""
23
+ },
21
24
  "oauth2": {
22
25
  "azure": {
23
26
  "tokenEndpointAuthMethod": "client_secret_post",
@@ -0,0 +1,190 @@
1
+ /*
2
+ * The ti-engine is an open source, free to use—both for personal and commercial projects—framework for the creation of microservice-based solutions using node.js.
3
+ * Copyright © 2021-2026 Boris Kostadinov <kostadinov.boris@gmail.com>
4
+ * This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
5
+ * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
6
+ * You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
7
+ */
8
+
9
+ "use strict";
10
+
11
+ const path = require( "node:path" );
12
+ const fs = require( "node:fs" );
13
+
14
+ /** @import { TiApplicationInfo } from "#definitions" */
15
+
16
+ /**
17
+ * Matches the leading npm scope of a package name (`@ti-engine/competence` → `competence`).
18
+ *
19
+ * @type {RegExp}
20
+ */
21
+ const RE_PACKAGE_SCOPE = /^@[^/]+\//;
22
+
23
+ /**
24
+ * Matches the first character that can open the contact suffix of a `package.json` author string — the `<` of the
25
+ * e-mail or the `(` of the homepage.
26
+ *
27
+ * @type {RegExp}
28
+ */
29
+ const RE_AUTHOR_CONTACT_START = /[<(]/;
30
+
31
+ /**
32
+ * Turns an npm package name into a human-readable display name — the scope is dropped and each dash/underscore
33
+ * separated word is capitalized (`@ti-engine/web-framework` → `Web Framework`). Used only when neither the manifest
34
+ * nor the environment supplies an explicit display name.
35
+ *
36
+ * @method
37
+ * @param {string} packageName
38
+ * @returns {string}
39
+ * @private
40
+ */
41
+ function toDisplayName( packageName ) {
42
+ const bare = String( packageName || "" ).replace( RE_PACKAGE_SCOPE, "" ).trim();
43
+ if ( !bare ) {
44
+ return "";
45
+ }
46
+ return bare
47
+ .split( /[-_.\s]+/ )
48
+ .filter( ( word ) => word.length > 0 )
49
+ .map( ( word ) => word.charAt( 0 ).toUpperCase() + word.slice( 1 ) )
50
+ .join( " " );
51
+ }
52
+
53
+ /**
54
+ * Reduces a `package.json` author entry — either the string form or the object form — to a bare display name,
55
+ * dropping the e-mail address and homepage that npm allows to be inlined in the string form.
56
+ * <br/>
57
+ * NOTE: npm's string form is `Name <email> (url)`, where both bracketed parts are optional **suffixes** — they are
58
+ * never embedded inside the name. So the name is simply everything before the first `<` or `(`. This deliberately
59
+ * does NOT globally remove `<…>` spans: a replace of that shape reads as an attempt to strip HTML tags, which
60
+ * CodeQL flags as an incomplete multi-character sanitizer (`js/incomplete-multi-character-sanitization`, high) —
61
+ * correctly, since one pass over `<<a>b>` leaves a stray `<`. Truncating at the delimiter matches the actual
62
+ * grammar and cannot leave a partial span behind.
63
+ *
64
+ * @method
65
+ * @param {string|Object} author
66
+ * @returns {string}
67
+ * @private
68
+ */
69
+ function toAuthorName( author ) {
70
+ if ( author && typeof author === "object" ) {
71
+ return String( author.name || "" ).trim();
72
+ }
73
+ const declared = String( author || "" );
74
+ const contactStart = declared.search( RE_AUTHOR_CONTACT_START );
75
+ return ( contactStart === -1 ? declared : declared.slice( 0, contactStart ) ).trim();
76
+ }
77
+
78
+ /**
79
+ * Reduces a `package.json` repository entry — either the string shorthand or the object form — to a plain URL,
80
+ * stripping the `git+` prefix and `.git` suffix npm accepts so the result is browser-openable.
81
+ *
82
+ * @method
83
+ * @param {string|Object} repository
84
+ * @returns {string}
85
+ * @private
86
+ */
87
+ function toRepositoryUrl( repository ) {
88
+ const raw = ( repository && typeof repository === "object" ) ? repository.url : repository;
89
+ const url = String( raw || "" ).trim();
90
+ if ( !url ) {
91
+ return "";
92
+ }
93
+ return url.replace( /^git\+/, "" ).replace( /\.git$/, "" );
94
+ }
95
+
96
+ /**
97
+ * Returns the trimmed string value of `value`, or `fallback` when it is absent or blank. Keeps the builder below
98
+ * free of repeated `String( … ).trim() || …` noise while treating a whitespace-only manifest field as absent.
99
+ *
100
+ * @method
101
+ * @param {*} value
102
+ * @param {string} [fallback=""]
103
+ * @returns {string}
104
+ * @private
105
+ */
106
+ function text( value, fallback = "" ) {
107
+ const resolved = String( value === undefined || value === null ? "" : value ).trim();
108
+ return resolved || fallback;
109
+ }
110
+
111
+ /**
112
+ * Builds the normalized application-information descriptor that backs the framework "About" screen.
113
+ * <br/>
114
+ * The function is PURE — everything it needs is injected — so the whole resolution order (manifest → environment
115
+ * override) is unit-testable without touching the filesystem or `process.env`. The impure half, reading the
116
+ * consuming application's manifest, is {@link readApplicationManifest}.
117
+ * <br/>
118
+ * Resolution order for the three overridable fields is manifest first, environment last:
119
+ * - `TI_WEB_APP_NAME` overrides `manifest.displayName` / a display name derived from `manifest.name`;
120
+ * - `TI_WEB_APP_VERSION` overrides `manifest.version`;
121
+ * - `TI_WEB_APP_RELEASE_DATE` overrides `manifest.releaseDate`.
122
+ * <br/>
123
+ * The environment wins because it is how a container image stamps facts that its baked-in manifest cannot know —
124
+ * most importantly the build/release date, for which `package.json` has no standard field at all.
125
+ *
126
+ * @method
127
+ * @param {Object} [options]
128
+ * @param {Object} [options.manifest] A `package.json`-shaped object for the consuming application.
129
+ * @param {Object} [options.env] The environment source (injectable for testing).
130
+ * @param {Array<{name: string, version: string}>} [options.components] Framework component versions to list.
131
+ * @param {Object} [options.runtime] Runtime facts (node/platform/instance). Included verbatim when present; the
132
+ * caller decides whether the current session is allowed to see them.
133
+ * @returns {TiApplicationInfo}
134
+ * @public
135
+ */
136
+ function buildApplicationInfo( options = {} ) {
137
+ const manifest = ( options.manifest && typeof options.manifest === "object" ) ? options.manifest : {};
138
+ const env = ( options.env && typeof options.env === "object" ) ? options.env : {};
139
+
140
+ const packageName = text( manifest.name );
141
+ const name = text( env.TI_WEB_APP_NAME, text( manifest.displayName, toDisplayName( packageName ) ) );
142
+ const author = toAuthorName( manifest.author );
143
+ const homepage = text( manifest.homepage, toRepositoryUrl( manifest.repository ) );
144
+
145
+ const components = ( Array.isArray( options.components ) ? options.components : [] )
146
+ .map( ( component ) => ( {
147
+ name: text( component && component.name ),
148
+ version: text( component && component.version )
149
+ } ) )
150
+ .filter( ( component ) => component.name.length > 0 );
151
+
152
+ return {
153
+ name: name,
154
+ packageName: packageName,
155
+ version: text( env.TI_WEB_APP_VERSION, text( manifest.version ) ),
156
+ releaseDate: text( env.TI_WEB_APP_RELEASE_DATE, text( manifest.releaseDate ) ),
157
+ description: text( manifest.description ),
158
+ license: text( manifest.license ),
159
+ homepage: homepage,
160
+ author: author,
161
+ components: components,
162
+ runtime: ( options.runtime && typeof options.runtime === "object" ) ? { ...options.runtime } : null,
163
+ sections: []
164
+ };
165
+ }
166
+
167
+ /**
168
+ * Reads the consuming application's `package.json`. This is the one impure function in this module.
169
+ * <br/>
170
+ * NOTE: A missing or malformed manifest resolves to an empty object rather than throwing — an informational screen
171
+ * must never be the reason a request fails, and {@link buildApplicationInfo} produces a usable (if sparse)
172
+ * descriptor from `{}`.
173
+ *
174
+ * @method
175
+ * @param {string} [directory=process.cwd()] The directory holding the manifest.
176
+ * @returns {Object}
177
+ * @public
178
+ */
179
+ function readApplicationManifest( directory = process.cwd() ) {
180
+ try {
181
+ const manifestPath = path.join( directory, "package.json" );
182
+ const contents = fs.readFileSync( manifestPath, "utf8" );
183
+ const parsed = JSON.parse( contents );
184
+ return ( parsed && typeof parsed === "object" ) ? parsed : {};
185
+ } catch {
186
+ return {};
187
+ }
188
+ }
189
+
190
+ module.exports = { buildApplicationInfo, readApplicationManifest };