@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.
- package/CHANGELOG.md +92 -0
- package/README.md +52 -0
- package/bin/build/hash-password.js +36 -0
- package/bin/config/local-users.example.json +8 -0
- package/bin/localization/web-server-labels.json +165 -1
- package/bin/static/fragments/components/component-sidebar.html +25 -0
- package/bin/static/fragments/frame-about.html +96 -0
- package/bin/static/fragments/frame-login.html +6 -1
- package/bin/static/fragments/frame-profile.html +103 -3
- package/bin/static/scripts/ti-framework.css +143 -0
- package/bin/static/scripts/ti-framework.js +339 -0
- package/bin/web-app-manager.js +235 -2
- package/bin/web-server.js +18 -1
- package/bin/web-server.json +3 -0
- package/components/application-info.js +190 -0
- package/components/auth-manager.js +159 -18
- package/components/definitions.types.js +65 -0
- package/components/local-user-directory.js +428 -0
- package/components/web-config-env.js +6 -1
- package/components/web-handlers.js +10 -2
- package/package.json +15 -2
- package/types/bin/web-app-manager.d.ts +95 -1
- package/types/bin/web-server.d.ts +19 -1
- package/types/components/application-info.d.ts +53 -0
- package/types/components/auth-manager.d.ts +7 -0
- package/types/components/definitions.types.d.ts +166 -0
- package/types/components/local-user-directory.d.ts +111 -0
- package/types/components/web-config-env.d.ts +1 -1
package/bin/web-app-manager.js
CHANGED
|
@@ -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 {
|
|
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
|
package/bin/web-server.json
CHANGED
|
@@ -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 };
|