@ti-engine/web-framework 1.19.0 → 1.20.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.
Files changed (52) hide show
  1. package/.env +4 -4
  2. package/CHANGELOG.md +384 -353
  3. package/README.md +73 -73
  4. package/bin/build/post-install.js +18 -18
  5. package/bin/localization/web-server-labels.json +27 -27
  6. package/bin/static/.well-known/appspecific/com.chrome.devtools.json +5 -5
  7. package/bin/static/fragments/components/component-notification-bar.html +21 -21
  8. package/bin/static/fragments/components/component-sidebar.html +33 -33
  9. package/bin/static/fragments/components/component-tooltip.html +10 -10
  10. package/bin/static/fragments/components/component-topbar.html +5 -5
  11. package/bin/static/fragments/frame-administration.html +2 -2
  12. package/bin/static/fragments/frame-application.html +18 -18
  13. package/bin/static/fragments/frame-dashboard.html +2 -2
  14. package/bin/static/fragments/frame-login.html +119 -119
  15. package/bin/static/fragments/frame-not-found.html +2 -2
  16. package/bin/static/fragments/frame-profile.html +2 -2
  17. package/bin/static/index.html +22 -22
  18. package/bin/static/scripts/ti-charts.js +1591 -1591
  19. package/bin/static/scripts/ti-framework.css +3194 -3194
  20. package/bin/static/scripts/ti-framework.js +1427 -1427
  21. package/bin/static/scripts/ti-theme-black-glass.css +216 -216
  22. package/bin/static/scripts/ti-theme-daylight.css +87 -87
  23. package/bin/web-app-manager.js +660 -663
  24. package/bin/web-server.js +936 -937
  25. package/bin/web-server.json +48 -48
  26. package/components/admin-config-handlers.js +95 -92
  27. package/components/auth-manager.js +438 -442
  28. package/components/authorization.js +135 -135
  29. package/components/config-change-notifier.js +98 -98
  30. package/components/config-registry.js +257 -260
  31. package/components/config-service.js +363 -360
  32. package/components/config-store.js +244 -246
  33. package/components/definitions.types.js +28 -26
  34. package/components/session-store.js +113 -110
  35. package/components/user.js +134 -132
  36. package/components/web-config-env.js +85 -85
  37. package/components/web-handlers.js +803 -800
  38. package/package.json +139 -67
  39. package/types/bin/web-app-manager.d.ts +194 -0
  40. package/types/bin/web-server.d.ts +373 -0
  41. package/types/components/admin-config-handlers.d.ts +11 -0
  42. package/types/components/auth-manager.d.ts +125 -0
  43. package/types/components/authorization.d.ts +54 -0
  44. package/types/components/config-change-notifier.d.ts +73 -0
  45. package/types/components/config-registry.d.ts +149 -0
  46. package/types/components/config-service.d.ts +218 -0
  47. package/types/components/config-store.d.ts +128 -0
  48. package/types/components/definitions.types.d.ts +31 -0
  49. package/types/components/session-store.d.ts +56 -0
  50. package/types/components/user.d.ts +83 -0
  51. package/types/components/web-config-env.d.ts +17 -0
  52. package/types/components/web-handlers.d.ts +23 -0
@@ -1,663 +1,660 @@
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-2025 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
- const exceptions = require( "@ti-engine/core/exceptions" );
10
- const tools = require( "@ti-engine/core/tools" );
11
- const localization = require( "@ti-engine/core/localization" );
12
- const path = require( "node:path" );
13
- const fs = require( "node:fs" );
14
- const configRegistry = require( "#config-registry" );
15
- const configService = require( "#config-service" );
16
- const authorization = require( "#authorization" );
17
-
18
- const RE_NONCE_ATTR = /\{ti-nonce-placeholder}/g;
19
- const RE_CSRF_ATTR = /\{ti-csrf-placeholder}/g;
20
- const RE_HTMX_CONFIG = /\{ti-htmx-config-placeholder}/g;
21
- const RE_CSP_NONCE = /^[A-Za-z0-9+/=_-]{16,}$/;
22
- const TI_NESTED_FRAME_PLACEHOLDER = "ti-nested-frame-placeholder";
23
- const OAUTH_METHODS = [ "openid-google", "openid-azure" ];
24
- const ALL_METHODS = [ "local", "openid-google", "openid-azure" ];
25
- const RE_AUTH_MARKERS = /<!--\/?ti-auth-(?:divider|social|none|method(?::[a-z-]+)?)-->/g;
26
-
27
- /**
28
- * Removes every `<openMarker>…<closeMarker>` span (inclusive) from `html` in a single linear pass. The markers are
29
- * matched as fixed strings via `indexOf`, so — unlike an `openMarker[\s\S]*?closeMarker` regular expression under a
30
- * global replace this cannot exhibit super-linear backtracking on hostile input containing many opening markers
31
- * (CodeQL js/polynomial-redos). Matches the lazy-regex semantics: each opening marker pairs with the *next* closing
32
- * marker after it. An opening marker with no matching closing marker is left untouched (the caller strips any stray
33
- * markers afterwards with {@link RE_AUTH_MARKERS}).
34
- *
35
- * @param {string} html
36
- * @param {string} openMarker
37
- * @param {string} closeMarker
38
- * @returns {string}
39
- */
40
- function stripMarkerSpans( html, openMarker, closeMarker ) {
41
- let result = "";
42
- let cursor = 0;
43
- for ( ; ; ) {
44
- const open = html.indexOf( openMarker, cursor );
45
- if ( open === -1 ) {
46
- result += html.slice( cursor );
47
- break;
48
- }
49
- const close = html.indexOf( closeMarker, open + openMarker.length );
50
- if ( close === -1 ) {
51
- result += html.slice( cursor );
52
- break;
53
- }
54
- result += html.slice( cursor, open );
55
- cursor = close + closeMarker.length;
56
- }
57
- return result;
58
- }
59
-
60
- /**
61
- * Gates the login-page authentication markup to the effective enabled methods. The login fragment delimits blocks
62
- * with HTML-comment markers: `<!--ti-auth-method:METHOD-->…<!--/ti-auth-method-->` around each method's control
63
- * (the `local` credentials form and each OpenID provider button), `<!--ti-auth-divider-->…<!--/ti-auth-divider-->`
64
- * around the "or continue with" separator, `<!--ti-auth-social-->…<!--/ti-auth-social-->` around the SSO button
65
- * group, and `<!--ti-auth-none-->…<!--/ti-auth-none-->` around a "no method configured" fallback. It removes the
66
- * block for any method that is not enabled, drops the social group when no SSO provider is enabled, shows the
67
- * divider only when a local form AND at least one SSO provider are both present, and shows the fallback only when
68
- * nothing is enabled. Any remaining markers are stripped so clean HTML ships. Fragments without these markers
69
- * (every non-login fragment) are returned unchanged.
70
- *
71
- * @param {string} html
72
- * @param {string[]} [enabledMethods] The effective enabled authentication methods.
73
- * @returns {string}
74
- */
75
- function applyAuthMethodVisibility( html, enabledMethods ) {
76
- let result = String( html );
77
- const enabled = Array.isArray( enabledMethods ) ? enabledMethods : [];
78
- const localEnabled = enabled.includes( "local" );
79
- const anyOAuth = OAUTH_METHODS.some( ( method ) => enabled.includes( method ) );
80
-
81
- // Drop the block for each authentication method that is not enabled.
82
- ALL_METHODS.forEach( ( method ) => {
83
- if ( !enabled.includes( method ) ) {
84
- result = stripMarkerSpans( result, "<!--ti-auth-method:" + method + "-->", "<!--/ti-auth-method-->" );
85
- }
86
- } );
87
-
88
- // Drop the SSO button group when no OpenID provider is enabled.
89
- if ( !anyOAuth ) {
90
- result = stripMarkerSpans( result, "<!--ti-auth-social-->", "<!--/ti-auth-social-->" );
91
- }
92
-
93
- // Show the "or continue with" divider only when BOTH a local form and at least one SSO provider are present.
94
- if ( !( localEnabled && anyOAuth ) ) {
95
- result = stripMarkerSpans( result, "<!--ti-auth-divider-->", "<!--/ti-auth-divider-->" );
96
- }
97
-
98
- // Show the "no method configured" fallback only when nothing is enabled.
99
- if ( localEnabled || anyOAuth ) {
100
- result = stripMarkerSpans( result, "<!--ti-auth-none-->", "<!--/ti-auth-none-->" );
101
- }
102
-
103
- return result.replace( RE_AUTH_MARKERS, "" );
104
- }
105
-
106
- /**
107
- * A generic web application manager that handles the rendering and behavior of web application views. It is designed to be extended by specific web application
108
- * managers for each web application you want to implement with the ti-engine web framework.
109
- * <br/>
110
- * NOTE: You should not instantiate this class directly. Instead, extend it and override the abstract methods as needed. Additionally, you should configure your
111
- * ti-engine web server 'TiWebApplicationConfig' settings by specifying the 'classPath' that corresponds to your web application manager. The path should be
112
- * relative to the intended process's working directory.
113
- *
114
- * @class TiWebAppManager
115
- * @abstract
116
- * @public
117
- */
118
- class TiWebAppManager {
119
-
120
- #webAppIdentifier;
121
- #fragments = {};
122
- #staticFileCache = {};
123
- #staticFileCacheEnabled;
124
- #enabledAuthMethods = [];
125
-
126
- /**
127
- * @constructor
128
- * @param {string} identifier The identifier for this web application. Should be unique and recognizable.
129
- * @throws {TiException.E_GEN_ABSTRACT_CLASS_INIT} If this class is instantiated directly.
130
- */
131
- constructor( identifier ) {
132
- // Make sure this abstract class cannot be instantiated:
133
- if ( new.target === TiWebAppManager ) {
134
- throw exceptions.raise( exceptions.exceptionCode.E_GEN_ABSTRACT_CLASS_INIT, { name: this.constructor.name } );
135
- }
136
-
137
- this.#webAppIdentifier = identifier;
138
- this.#staticFileCacheEnabled = ( process.env.TI_WEB_APP_STATIC_CACHE_DISABLED !== "true" );
139
-
140
- // Define the default HTML fragments for the application:
141
- this.#fragments[ 'home' ] = {
142
- path: "index.html",
143
- components: [ "component-notification-bar" ]
144
- };
145
- this.#fragments[ 'application-main' ] = {
146
- title: "Application",
147
- path: "fragments/frame-application.html",
148
- components: [ "component-topbar", "component-sidebar", "component-notification-bar", "component-sidebar-flyout" ]
149
- };
150
- this.#fragments[ 'login' ] = {
151
- title: "Login",
152
- path: "fragments/frame-login.html"
153
- };
154
- this.#fragments[ 'dashboard' ] = {
155
- title: "Dashboard",
156
- path: "fragments/frame-dashboard.html"
157
- };
158
- this.#fragments[ 'administration' ] = {
159
- title: "Administration",
160
- path: "fragments/frame-administration.html"
161
- };
162
- this.#fragments[ 'profile' ] = {
163
- title: "Profile",
164
- path: "fragments/frame-profile.html"
165
- };
166
- this.#fragments[ 'not-found' ] = {
167
- title: "Not Found",
168
- path: "fragments/frame-not-found.html"
169
- };
170
- }
171
-
172
- /* Public interface */
173
-
174
- /**
175
- * Returns the identifier for this web application.
176
- *
177
- * @property
178
- * @returns {string}
179
- * @public
180
- */
181
- get webAppIdentifier() {
182
- return this.#webAppIdentifier;
183
- }
184
-
185
- /**
186
- * Adds a new HTML fragment to the web application.
187
- * <br/>
188
- * NOTE: This method should only be called during the initialization phase of your web application manager.
189
- *
190
- * @method
191
- * @param {string} identifier
192
- * @param {Object} fragment The fragment descriptor (`{ title, path, components }`). May also carry an optional
193
- * `roles` array (`Array<string|number>`): when present, the default {@link TiWebAppManager#verifyAccess} serves
194
- * the fragment only to sessions holding at least one of those roles; omit it (or leave empty) for a public screen.
195
- * @throws {TiException.E_GEN_UNALLOWED_OVERRIDE} If a fragment with the same identifier already exists.
196
- * @public
197
- */
198
- addFragment( identifier, fragment ) {
199
- if ( this.#fragments[ identifier ] === undefined ) {
200
- this.#fragments[ identifier ] = fragment;
201
- } else {
202
- throw exceptions.raise( exceptions.exceptionCode.E_GEN_UNALLOWED_OVERRIDE, { identifier: identifier } );
203
- }
204
- }
205
-
206
- /**
207
- * Registers an editable configuration document with the framework config registry (JSON Schema + semantic
208
- * validators + default value + editor metadata). Call during initialization. See {@link ConfigRegistry#register}.
209
- *
210
- * @method
211
- * @param {string} configKey
212
- * @param {Object} definition
213
- * @returns {TiWebAppManager} this (chainable)
214
- * @public
215
- */
216
- registerConfigDocument( configKey, definition ) {
217
- configRegistry.instance.register( configKey, definition );
218
- return this;
219
- }
220
-
221
- /**
222
- * Registers a JSON Schema that is referenced (via `$ref`) by config-document schemas but is not itself a document.
223
- *
224
- * @method
225
- * @param {Object} schema
226
- * @returns {TiWebAppManager} this (chainable)
227
- * @public
228
- */
229
- registerConfigSchema( schema ) {
230
- configRegistry.instance.addSchema( schema );
231
- return this;
232
- }
233
-
234
- /**
235
- * Registers a composite (entity) editor with the framework config service — a `compose(docs)`/`decompose(edited,docs)`
236
- * pair over one or more documents. Call during initialization. See {@link ConfigService#registerEditor}.
237
- *
238
- * @method
239
- * @param {string} editorKey
240
- * @param {Object} definition
241
- * @returns {TiWebAppManager} this (chainable)
242
- * @public
243
- */
244
- registerConfigEditor( editorKey, definition ) {
245
- configService.instance.registerEditor( editorKey, definition );
246
- return this;
247
- }
248
-
249
- /**
250
- * Used to clear the static file cache. This is useful for testing purposes to ensure that the web server is always serving fresh content.
251
- *
252
- * @method
253
- * @public
254
- */
255
- clearStaticFileCache() {
256
- this.#staticFileCache = {};
257
- }
258
-
259
- /**
260
- * Sets the effective enabled authentication methods used to gate login-page provider buttons. The web server
261
- * calls this at startup, after the auth manager has dropped any enabled-but-unconfigured OpenID providers.
262
- *
263
- * @method
264
- * @param {string[]} methods
265
- * @public
266
- */
267
- setEnabledAuthMethods( methods ) {
268
- this.#enabledAuthMethods = Array.isArray( methods ) ? [ ...methods ] : [];
269
- }
270
-
271
- /**
272
- * Optional HTML transformation hook.
273
- * <br/>
274
- * NOTE: Override in subclasses to add nonces or other dynamic data to outgoing HTML.
275
- *
276
- * @method
277
- * @param {string} html
278
- * @param {Object} [options]
279
- * @param {string} [options.csrfToken] Optional CSRF token to inject into the HTML.
280
- * @param {boolean} [options.isHome] Optional flag to indicate whether the requested route is the home page.
281
- * @param {string} [options.nonce] Optional CSP nonce to inject into inline scripts/styles.
282
- * @param {string} [options.title] Optional title to replace the placeholder in the HTML.
283
- * @returns {Promise<string>}
284
- * @virtual
285
- * @public
286
- */
287
- transformHtml( html, options = {} ) {
288
- return new Promise( ( resolve, reject ) => {
289
- let transformedHtml = String( html );
290
-
291
- // Insert nonce in all placeholder locations. If nonce is not provided or is invalid, this will use an empty string instead to remove the placeholder:
292
- const nonce = ( typeof options?.nonce === "string" && RE_CSP_NONCE.test( options?.nonce ) ) ? options?.nonce : "";
293
- transformedHtml = transformedHtml.replaceAll( RE_NONCE_ATTR, nonce );
294
- if ( options.isHome ) {
295
- let htmxConfig = {
296
- inlineScriptNonce: nonce,
297
- inlineStyleNonce: nonce,
298
- allowEval: false,
299
- refreshOnHistoryMiss: true,
300
- historyCacheSize: 0
301
- };
302
- transformedHtml = transformedHtml.replace( RE_HTMX_CONFIG, JSON.stringify( htmxConfig ) );
303
- }
304
-
305
- const csrfToken = ( typeof options?.csrfToken === "string" ) ? options?.csrfToken : "";
306
- transformedHtml = transformedHtml.replaceAll( RE_CSRF_ATTR, csrfToken );
307
-
308
- transformedHtml = transformedHtml.replace( "{ti-title-placeholder}", options.title || "" );
309
-
310
- // Gate login-page OpenID provider buttons to the effective enabled auth methods (no-op on other fragments).
311
- transformedHtml = applyAuthMethodVisibility( transformedHtml, this.#enabledAuthMethods );
312
-
313
- resolve( transformedHtml );
314
- } );
315
- }
316
-
317
- /**
318
- * Used to assemble the complete HTML view for the requested route, including nested HTML fragments.
319
- *
320
- * @method
321
- * @param {TiSession} session
322
- * @param {string[]} staticContentPaths
323
- * @param {string} route
324
- * @param {Object} [options]
325
- * @param {string} [options.csrfToken] Optional CSRF token to inject into the HTML.
326
- * @param {boolean} [options.isPartial] Optional flag to indicate whether the requested route is a partial load of a fragment.
327
- * @param {string} [options.view] Optional view name to load within this route.
328
- * @param {string} [options.nonce] Optional CSP nonce to inject into inline scripts/styles.
329
- * @returns {Promise<string>}
330
- * @public
331
- */
332
- assembleHtmlView( session, staticContentPaths, route, options = {} ) {
333
- return new Promise( ( resolve, reject ) => {
334
- let fragment;
335
- let getHtmlPromises = [];
336
- let localOptions = ( options && typeof options === "object" ) ? { ...options } : {};
337
-
338
- if ( route === "/" ) {
339
- fragment = this.#fragments[ 'home' ];
340
- getHtmlPromises.push( this.#getHtmlFragment( session, staticContentPaths, fragment, { ...localOptions, isHome: true } ) );
341
- } else if ( route === "/app/error" ) {
342
- // TODO: This endpoint is for testing purposes only. Remove later.
343
- return reject( exceptions.raise( exceptions.exceptionCode.E_WEB_INVALID_REQUEST_METHOD ) );
344
- } else if ( route === "/app" || route === "/app/enter" ) {
345
- fragment = ( session && session.user ) ? this.#fragments[ 'application-main' ] : this.#fragments[ 'login' ];
346
- getHtmlPromises.push( this.#getHtmlFragment( session, staticContentPaths, fragment, localOptions ) );
347
- } else if ( route === "/not-found" ) {
348
- getHtmlPromises.push( this.#getHtmlFragment( session, staticContentPaths, this.#fragments[ 'home' ], { ...localOptions, isHome: true, title: this.#fragments[ 'not-found' ].title } ) );
349
- getHtmlPromises.push( this.#getHtmlFragment( session, staticContentPaths, this.#fragments[ 'not-found' ], localOptions ) );
350
- } else {
351
- fragment = this.#fragments[ options.view ];
352
- if ( !fragment ) {
353
- // Abort execution if the requested view is not found:
354
- return reject( exceptions.raise( exceptions.exceptionCode.E_WEB_INVALID_REQUEST_URI ) );
355
- } else {
356
- // This handles application refreshes from nested frames:
357
- if ( options.isPartial !== true ) {
358
- getHtmlPromises.push( this.#getHtmlFragment( session, staticContentPaths, this.#fragments[ 'home' ], {
359
- ...localOptions,
360
- isHome: true,
361
- title: fragment.title
362
- } ) );
363
- getHtmlPromises.push( this.#getHtmlFragment( session, staticContentPaths, this.#fragments[ 'application-main' ], localOptions ) );
364
- }
365
- getHtmlPromises.push( this.#getHtmlFragment( session, staticContentPaths, fragment, localOptions ) );
366
- }
367
- }
368
-
369
- Promise.all( getHtmlPromises ).then( ( filesData ) => {
370
- let assembledHtml = undefined;
371
- filesData.forEach( ( fileData ) => {
372
- // There should always be at most one ti-nested-frame-placeholder element in each HTML fragment:
373
- assembledHtml = ( assembledHtml ) ? this.#replacePlaceholderElement( assembledHtml, TI_NESTED_FRAME_PLACEHOLDER, fileData ) : fileData;
374
- } );
375
- resolve( assembledHtml );
376
- } ).catch( ( error ) => {
377
- reject( exceptions.raise( error ) );
378
- } );
379
- } );
380
- }
381
-
382
- /**
383
- * Used to process a request for a data resource.
384
- *
385
- * @method
386
- * @param {TiSession} session
387
- * @param {string} view
388
- * @param {Object} [options]
389
- * @returns {Promise<Object>}
390
- * @virtual
391
- * @public
392
- */
393
- processDataRequest( session, view, options = {} ) {
394
- return new Promise( ( resolve, reject ) => {
395
- if ( view === "config" ) {
396
- resolve( {
397
- labels: localization.getAllLabels( session?.language ),
398
- auth: {
399
- isAuthenticated: Boolean( session && session.user )
400
- }
401
- } );
402
- } else {
403
- reject( exceptions.raise( exceptions.exceptionCode.E_WEB_INVALID_REQUEST_URI, { view: view } ) );
404
- }
405
- } );
406
- }
407
-
408
- /**
409
- * Used to process an application service request.
410
- *
411
- * @method
412
- * @param {TiSession} session
413
- * @param {string} service
414
- * @param {Object} params
415
- * @returns {Promise<Object>}
416
- * @virtual
417
- * @public
418
- */
419
- processServiceRequest( session, service, params ) {
420
- return new Promise( ( resolve, reject ) => {
421
- reject( exceptions.raise( exceptions.exceptionCode.E_WEB_INVALID_REQUEST_URI, { service: service } ) );
422
- } );
423
- }
424
-
425
- /**
426
- * Used to verify whether the current user has access to the requested resource. The default implementation gates
427
- * HTML fragments by their declared `roles`: a fragment registered via {@link TiWebAppManager#addFragment} with a
428
- * `roles` array is served only to sessions holding at least one of those roles (see {@link addFragment}); a
429
- * fragment with no `roles` is public to any authenticated user. This makes role-restricted screens unreachable by
430
- * direct URL, not merely hidden in the UI. Override in subclasses only to implement additional/alternative checks.
431
- *
432
- * @method
433
- * @virtual
434
- * @param {TiSession} session
435
- * @param {Object} resource The fragment descriptor; its optional `resource.roles` lists the roles permitted to load it.
436
- * @returns {Promise}
437
- * @exception {TiException.E_SEC_UNAUTHORIZED_ACCESS} (403) When the session holds none of the fragment's required roles.
438
- * @public
439
- */
440
- verifyAccess( session, resource ) {
441
- return new Promise( ( resolve, reject ) => {
442
- const requiredRoles = ( resource && resource.roles ) ? resource.roles : null;
443
- const userRoles = ( session && session.user && session.user.roles ) || [];
444
- if ( authorization.isAccessAllowed( requiredRoles, userRoles ) ) {
445
- return resolve();
446
- }
447
- reject( exceptions.raise( exceptions.exceptionCode.E_SEC_UNAUTHORIZED_ACCESS, null, exceptions.httpCode.C_403 ) );
448
- } );
449
- }
450
-
451
- /* Private interface */
452
-
453
- /**
454
- * Returns the HTML fragment for the requested route.
455
- *
456
- * @method
457
- * @param {TiSession} session
458
- * @param {string[]} staticContentPaths
459
- * @param {Object} fragment
460
- * @param {Object} [options]
461
- * @param {string} [options.csrfToken] Optional CSRF token to inject into the HTML.
462
- * @param {boolean} [options.isHome] Optional flag to indicate whether the requested route is the home page.
463
- * @param {string} [options.nonce] Optional CSP nonce to inject into inline scripts/styles.
464
- * @returns {Promise<string>}
465
- * @private
466
- */
467
- #getHtmlFragment( session, staticContentPaths, fragment, options = {} ) {
468
- return new Promise( ( resolve, reject ) => {
469
- this.verifyAccess( session, fragment ).then( () => {
470
- return this.#locateStaticFile( staticContentPaths, fragment.path );
471
- } ).then( ( fileData ) => {
472
- return this.#replaceComponentPlaceholders( fileData, staticContentPaths, fragment.components );
473
- } ).then( ( fileData ) => {
474
- return this.transformHtml( fileData, { ...options, title: fragment.title || options.title } );
475
- } ).then( ( fileData ) => {
476
- resolve( fileData );
477
- } ).catch( ( error ) => {
478
- reject( exceptions.raise( error ) );
479
- } );
480
- } );
481
- }
482
-
483
- /**
484
- * Attempts to locate the requested static file in the provided static content paths.
485
- *
486
- * @method
487
- * @param {string[]} staticContentPaths A list of directories to search for static content. If sent as expected by the web server, the first item in the array should be the system default path.
488
- * @param {string} filePath Relative path to the static file to locate.
489
- * @returns {Promise<string>}
490
- * @private
491
- */
492
- #locateStaticFile( staticContentPaths, filePath ) {
493
- return new Promise( ( resolve, reject ) => {
494
- if ( this.#staticFileCacheEnabled === true && this.#staticFileCache[ filePath ] !== undefined ) {
495
- resolve( this.#staticFileCache[ filePath ] );
496
- } else {
497
- let fullFilePath;
498
- // Search for the file in the static content paths in reverse order so that the default system path is checked last. This will ensure that
499
- // any fragment overrides are loaded first (i.e., fragments with the same relative path):
500
- for ( let idx = staticContentPaths.length - 1; idx >= 0; idx-- ) {
501
- const staticContentPath = staticContentPaths[ idx ];
502
- let potentialFilePath = path.join( staticContentPath, filePath );
503
- if ( fs.existsSync( potentialFilePath ) ) {
504
- fullFilePath = potentialFilePath;
505
- break;
506
- }
507
- }
508
-
509
- if ( fullFilePath !== undefined ) {
510
- fs.promises.readFile( fullFilePath, "utf8" ).then( ( fileData ) => {
511
- if ( this.#staticFileCacheEnabled === true ) {
512
- this.#staticFileCache[ filePath ] = fileData;
513
- }
514
- resolve( fileData );
515
- } ).catch( ( error ) => {
516
- reject( exceptions.raise( error ) );
517
- } );
518
- } else {
519
- reject( exceptions.raise( exceptions.exceptionCode.E_WEB_INVALID_REQUEST_URI ) );
520
- }
521
- }
522
- } );
523
- }
524
-
525
- /**
526
- * Used to replace the component placeholders in the HTML with the actual component HTML.
527
- *
528
- * @method
529
- * @param {string} html
530
- * @param {string[]} staticContentPaths
531
- * @param {Array<string>} components
532
- * @returns {Promise<string>}
533
- * @private
534
- */
535
- #replaceComponentPlaceholders( html, staticContentPaths, components ) {
536
- return new Promise( ( resolve, reject ) => {
537
- if ( components === undefined || components.length === 0 ) {
538
- resolve( html );
539
- } else {
540
- let transformedHtml = String( html );
541
- let promises = [];
542
- const componentData = {};
543
- tools.arrayUniques( components ).forEach( ( component ) => {
544
- promises.push( this.#locateStaticFile( staticContentPaths, `fragments/components/${ component }.html` ).then( ( fileData ) => {
545
- componentData[ component ] = fileData;
546
- } ) );
547
- } );
548
-
549
- Promise.all( promises ).then( () => {
550
- components.forEach( ( component ) => {
551
- transformedHtml = this.#replacePlaceholderElement( transformedHtml, `ti-${ component }-placeholder`, componentData[ component ] );
552
- } );
553
- resolve( transformedHtml );
554
- } ).catch( ( error ) => {
555
- reject( exceptions.raise( error ) );
556
- } );
557
- }
558
- } );
559
- }
560
-
561
- /**
562
- * Used to replace a placeholder element in the HTML with the provided replacement.
563
- * <br/>
564
- * The placeholder's attributes are exposed to the replacement HTML as `{ti-<attr-name>}` tokens, allowing the
565
- * component template to consume initial data without changing its `x-data` factory.
566
- * <br/>
567
- * If the replacement HTML contains a `<ti-slot></ti-slot>` (or self-closing `<ti-slot/>`) marker, the placeholder's
568
- * inner content replaces it precisely; otherwise, inner content is appended before the replacement's last closing
569
- * tag (legacy behaviour) so existing components keep working.
570
- *
571
- * @method
572
- * @param {string} html
573
- * @param {string} tagName
574
- * @param {string} replacement
575
- * @returns {string}
576
- * @private
577
- */
578
- #replacePlaceholderElement( html, tagName, replacement ) {
579
- const start = html.indexOf( `<${ tagName }` );
580
- if ( start === -1 ) {
581
- return html;
582
- }
583
- const gt = html.indexOf( ">", start );
584
- if ( gt === -1 ) {
585
- return html;
586
- }
587
- // Tolerate whitespace(s) before '/>' and attributes on the tag:
588
- let p = gt - 1;
589
- while ( p > start && /\s/.test( html[ p ] ) ) p--;
590
- const isSelfClosing = html[ p ] === "/";
591
- let end;
592
- let inner = "";
593
- if ( isSelfClosing ) {
594
- end = gt + 1;
595
- } else {
596
- const close = `</${ tagName }>`;
597
- end = html.indexOf( close, gt + 1 );
598
- if ( end === -1 ) {
599
- return html;
600
- }
601
- inner = html.slice( gt + 1, end );
602
- end += close.length;
603
- }
604
-
605
- // Substitute the placeholder's attributes as `{ti-<name>}` tokens inside the replacement HTML:
606
- const placeholderAttributes = this.#parsePlaceholderAttributes( html.slice( start, gt + 1 ) );
607
- let processedReplacement = replacement;
608
- Object.keys( placeholderAttributes ).forEach( ( name ) => {
609
- const value = placeholderAttributes[ name ];
610
- const token = `{ti-${ name }}`;
611
- // Use split/join for a literal replaceAll without regex escaping concerns:
612
- processedReplacement = processedReplacement.split( token ).join( value );
613
- } );
614
-
615
- let replacementWithInner = processedReplacement;
616
- const slotMatch = processedReplacement.match( /<ti-slot\b[^>]*>[\s\S]*?<\/ti-slot>|<ti-slot\b[^>]*\/>/ );
617
- if ( slotMatch ) {
618
- // If a slot marker exists, the placeholder's inner content (when present) replaces it. When inner is
619
- // empty, the slot's own default content (between <ti-slot> and </ti-slot>) is kept by unwrapping it:
620
- if ( inner ) {
621
- replacementWithInner = processedReplacement.replace( slotMatch[ 0 ], inner );
622
- } else {
623
- replacementWithInner = processedReplacement.replace( slotMatch[ 0 ], ( match ) => {
624
- const defaultMatch = match.match( /<ti-slot\b[^>]*>([\s\S]*?)<\/ti-slot>/ );
625
- return defaultMatch ? defaultMatch[ 1 ] : "";
626
- } );
627
- }
628
- } else if ( inner ) {
629
- const insertAt = processedReplacement.lastIndexOf( "</" );
630
- if ( insertAt !== -1 ) {
631
- replacementWithInner = processedReplacement.slice( 0, insertAt ) + inner + processedReplacement.slice( insertAt );
632
- } else {
633
- replacementWithInner = processedReplacement + inner;
634
- }
635
- }
636
-
637
- return html.slice( 0, start ) + replacementWithInner + html.slice( end );
638
- }
639
-
640
- /**
641
- * Parses the attribute name/value pairs declared on a placeholder element's opening tag.
642
- *
643
- * @method
644
- * @param {string} openingTag The full opening tag text, e.g. `<ti-foo-placeholder bar="baz">`.
645
- * @returns {Object<string, string>}
646
- * @private
647
- */
648
- #parsePlaceholderAttributes( openingTag ) {
649
- const attributes = {};
650
- // Strip the element name and the surrounding angle brackets so only the attribute string remains:
651
- const trimmed = openingTag.replace( /^<[^\s>/]+/, "" ).replace( /\/?>\s*$/, "" );
652
- const regex = /([a-zA-Z_][\w:.-]*)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/g;
653
- let match;
654
- while ( ( match = regex.exec( trimmed ) ) !== null ) {
655
- attributes[ match[ 1 ] ] = match[ 2 ] ?? match[ 3 ] ?? match[ 4 ] ?? "";
656
- }
657
- return attributes;
658
- }
659
-
660
- }
661
-
662
- module.exports = TiWebAppManager;
663
- module.exports.applyAuthMethodVisibility = applyAuthMethodVisibility;
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-2025 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
+ const exceptions = require( "@ti-engine/core/exceptions" );
10
+ const tools = require( "@ti-engine/core/tools" );
11
+ const localization = require( "@ti-engine/core/localization" );
12
+ const path = require( "node:path" );
13
+ const fs = require( "node:fs" );
14
+ const configRegistry = require( "#config-registry" );
15
+ const configService = require( "#config-service" );
16
+ const authorization = require( "#authorization" );
17
+
18
+ /** @import { TiSession } from "#definitions" */
19
+
20
+ const RE_NONCE_ATTR = /\{ti-nonce-placeholder}/g;
21
+ const RE_CSRF_ATTR = /\{ti-csrf-placeholder}/g;
22
+ const RE_HTMX_CONFIG = /\{ti-htmx-config-placeholder}/g;
23
+ const RE_CSP_NONCE = /^[A-Za-z0-9+/=_-]{16,}$/;
24
+ const TI_NESTED_FRAME_PLACEHOLDER = "ti-nested-frame-placeholder";
25
+ const OAUTH_METHODS = [ "openid-google", "openid-azure" ];
26
+ const ALL_METHODS = [ "local", "openid-google", "openid-azure" ];
27
+ const RE_AUTH_MARKERS = /<!--\/?ti-auth-(?:divider|social|none|method(?::[a-z-]+)?)-->/g;
28
+
29
+ /**
30
+ * Removes every `<openMarker>…<closeMarker>` span (inclusive) from `html` in a single linear pass. The markers are
31
+ * matched as fixed strings via `indexOf`, so unlike an `openMarker[\s\S]*?closeMarker` regular expression under a
32
+ * global replace this cannot exhibit super-linear backtracking on hostile input containing many opening markers
33
+ * (CodeQL js/polynomial-redos). Matches the lazy-regex semantics: each opening marker pairs with the *next* closing
34
+ * marker after it. An opening marker with no matching closing marker is left untouched (the caller strips any stray
35
+ * markers afterwards with {@link RE_AUTH_MARKERS}).
36
+ *
37
+ * @param {string} html
38
+ * @param {string} openMarker
39
+ * @param {string} closeMarker
40
+ * @returns {string}
41
+ */
42
+ function stripMarkerSpans( html, openMarker, closeMarker ) {
43
+ let result = "";
44
+ let cursor = 0;
45
+ for ( ; ; ) {
46
+ const open = html.indexOf( openMarker, cursor );
47
+ if ( open === -1 ) {
48
+ result += html.slice( cursor );
49
+ break;
50
+ }
51
+ const close = html.indexOf( closeMarker, open + openMarker.length );
52
+ if ( close === -1 ) {
53
+ result += html.slice( cursor );
54
+ break;
55
+ }
56
+ result += html.slice( cursor, open );
57
+ cursor = close + closeMarker.length;
58
+ }
59
+ return result;
60
+ }
61
+
62
+ /**
63
+ * Gates the login-page authentication markup to the effective enabled methods. The login fragment delimits blocks
64
+ * with HTML-comment markers: `<!--ti-auth-method:METHOD-->…<!--/ti-auth-method-->` around each method's control
65
+ * (the `local` credentials form and each OpenID provider button), `<!--ti-auth-divider-->…<!--/ti-auth-divider-->`
66
+ * around the "or continue with" separator, `<!--ti-auth-social-->…<!--/ti-auth-social-->` around the SSO button
67
+ * group, and `<!--ti-auth-none-->…<!--/ti-auth-none-->` around a "no method configured" fallback. It removes the
68
+ * block for any method that is not enabled, drops the social group when no SSO provider is enabled, shows the
69
+ * divider only when a local form AND at least one SSO provider are both present, and shows the fallback only when
70
+ * nothing is enabled. Any remaining markers are stripped so clean HTML ships. Fragments without these markers
71
+ * (every non-login fragment) are returned unchanged.
72
+ *
73
+ * @param {string} html
74
+ * @param {string[]} [enabledMethods] The effective enabled authentication methods.
75
+ * @returns {string}
76
+ */
77
+ function applyAuthMethodVisibility( html, enabledMethods ) {
78
+ let result = String( html );
79
+ const enabled = Array.isArray( enabledMethods ) ? enabledMethods : [];
80
+ const localEnabled = enabled.includes( "local" );
81
+ const anyOAuth = OAUTH_METHODS.some( ( method ) => enabled.includes( method ) );
82
+
83
+ // Drop the block for each authentication method that is not enabled.
84
+ ALL_METHODS.forEach( ( method ) => {
85
+ if ( !enabled.includes( method ) ) {
86
+ result = stripMarkerSpans( result, "<!--ti-auth-method:" + method + "-->", "<!--/ti-auth-method-->" );
87
+ }
88
+ } );
89
+
90
+ // Drop the SSO button group when no OpenID provider is enabled.
91
+ if ( !anyOAuth ) {
92
+ result = stripMarkerSpans( result, "<!--ti-auth-social-->", "<!--/ti-auth-social-->" );
93
+ }
94
+
95
+ // Show the "or continue with" divider only when BOTH a local form and at least one SSO provider are present.
96
+ if ( !( localEnabled && anyOAuth ) ) {
97
+ result = stripMarkerSpans( result, "<!--ti-auth-divider-->", "<!--/ti-auth-divider-->" );
98
+ }
99
+
100
+ // Show the "no method configured" fallback only when nothing is enabled.
101
+ if ( localEnabled || anyOAuth ) {
102
+ result = stripMarkerSpans( result, "<!--ti-auth-none-->", "<!--/ti-auth-none-->" );
103
+ }
104
+
105
+ return result.replace( RE_AUTH_MARKERS, "" );
106
+ }
107
+
108
+ /**
109
+ * A generic web application manager that handles the rendering and behavior of web application views. It is designed to be extended by specific web application
110
+ * managers for each web application you want to implement with the ti-engine web framework.
111
+ * <br/>
112
+ * NOTE: You should not instantiate this class directly. Instead, extend it and override the abstract methods as needed. Additionally, you should configure your
113
+ * ti-engine web server 'TiWebApplicationConfig' settings by specifying the 'classPath' that corresponds to your web application manager. The path should be
114
+ * relative to the intended process's working directory.
115
+ *
116
+ * @class TiWebAppManager
117
+ * @abstract
118
+ * @public
119
+ */
120
+ class TiWebAppManager {
121
+
122
+ #webAppIdentifier;
123
+ #fragments = {};
124
+ #staticFileCache = {};
125
+ #staticFileCacheEnabled;
126
+ #enabledAuthMethods = [];
127
+
128
+ /**
129
+ * @constructor
130
+ * @param {string} identifier The identifier for this web application. Should be unique and recognizable.
131
+ * @throws {TiException.E_GEN_ABSTRACT_CLASS_INIT} If this class is instantiated directly.
132
+ */
133
+ constructor( identifier ) {
134
+ // Make sure this abstract class cannot be instantiated:
135
+ if ( new.target === TiWebAppManager ) {
136
+ throw exceptions.raise( exceptions.exceptionCode.E_GEN_ABSTRACT_CLASS_INIT, { name: this.constructor.name } );
137
+ }
138
+
139
+ this.#webAppIdentifier = identifier;
140
+ this.#staticFileCacheEnabled = ( process.env.TI_WEB_APP_STATIC_CACHE_DISABLED !== "true" );
141
+
142
+ // Define the default HTML fragments for the application:
143
+ this.#fragments[ 'home' ] = {
144
+ path: "index.html",
145
+ components: [ "component-notification-bar" ]
146
+ };
147
+ this.#fragments[ 'application-main' ] = {
148
+ title: "Application",
149
+ path: "fragments/frame-application.html",
150
+ components: [ "component-topbar", "component-sidebar", "component-notification-bar", "component-sidebar-flyout" ]
151
+ };
152
+ this.#fragments[ 'login' ] = {
153
+ title: "Login",
154
+ path: "fragments/frame-login.html"
155
+ };
156
+ this.#fragments[ 'dashboard' ] = {
157
+ title: "Dashboard",
158
+ path: "fragments/frame-dashboard.html"
159
+ };
160
+ this.#fragments[ 'administration' ] = {
161
+ title: "Administration",
162
+ path: "fragments/frame-administration.html"
163
+ };
164
+ this.#fragments[ 'profile' ] = {
165
+ title: "Profile",
166
+ path: "fragments/frame-profile.html"
167
+ };
168
+ this.#fragments[ 'not-found' ] = {
169
+ title: "Not Found",
170
+ path: "fragments/frame-not-found.html"
171
+ };
172
+ }
173
+
174
+ /* Public interface */
175
+
176
+ /**
177
+ * Returns the identifier for this web application.
178
+ *
179
+ * @property
180
+ * @returns {string}
181
+ * @public
182
+ */
183
+ get webAppIdentifier() {
184
+ return this.#webAppIdentifier;
185
+ }
186
+
187
+ /**
188
+ * Adds a new HTML fragment to the web application.
189
+ * <br/>
190
+ * NOTE: This method should only be called during the initialization phase of your web application manager.
191
+ *
192
+ * @method
193
+ * @param {string} identifier
194
+ * @param {Object} fragment The fragment descriptor (`{ title, path, components }`). May also carry an optional
195
+ * `roles` array (`Array<string|number>`): when present, the default {@link TiWebAppManager#verifyAccess} serves
196
+ * the fragment only to sessions holding at least one of those roles; omit it (or leave empty) for a public screen.
197
+ * @throws {TiException.E_GEN_UNALLOWED_OVERRIDE} If a fragment with the same identifier already exists.
198
+ * @public
199
+ */
200
+ addFragment( identifier, fragment ) {
201
+ if ( this.#fragments[ identifier ] === undefined ) {
202
+ this.#fragments[ identifier ] = fragment;
203
+ } else {
204
+ throw exceptions.raise( exceptions.exceptionCode.E_GEN_UNALLOWED_OVERRIDE, { identifier: identifier } );
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Registers an editable configuration document with the framework config registry (JSON Schema + semantic
210
+ * validators + default value + editor metadata). Call during initialization. See {@link ConfigRegistry#register}.
211
+ *
212
+ * @method
213
+ * @param {string} configKey
214
+ * @param {Object} definition
215
+ * @returns {TiWebAppManager} this (chainable)
216
+ * @public
217
+ */
218
+ registerConfigDocument( configKey, definition ) {
219
+ configRegistry.instance.register( configKey, definition );
220
+ return this;
221
+ }
222
+
223
+ /**
224
+ * Registers a JSON Schema that is referenced (via `$ref`) by config-document schemas but is not itself a document.
225
+ *
226
+ * @method
227
+ * @param {Object} schema
228
+ * @returns {TiWebAppManager} this (chainable)
229
+ * @public
230
+ */
231
+ registerConfigSchema( schema ) {
232
+ configRegistry.instance.addSchema( schema );
233
+ return this;
234
+ }
235
+
236
+ /**
237
+ * Registers a composite (entity) editor with the framework config service — a `compose(docs)`/`decompose(edited,docs)`
238
+ * pair over one or more documents. Call during initialization. See {@link ConfigService#registerEditor}.
239
+ *
240
+ * @method
241
+ * @param {string} editorKey
242
+ * @param {Object} definition
243
+ * @returns {TiWebAppManager} this (chainable)
244
+ * @public
245
+ */
246
+ registerConfigEditor( editorKey, definition ) {
247
+ configService.instance.registerEditor( editorKey, definition );
248
+ return this;
249
+ }
250
+
251
+ /**
252
+ * Used to clear the static file cache. This is useful for testing purposes to ensure that the web server is always serving fresh content.
253
+ *
254
+ * @method
255
+ * @public
256
+ */
257
+ clearStaticFileCache() {
258
+ this.#staticFileCache = {};
259
+ }
260
+
261
+ /**
262
+ * Sets the effective enabled authentication methods used to gate login-page provider buttons. The web server
263
+ * calls this at startup, after the auth manager has dropped any enabled-but-unconfigured OpenID providers.
264
+ *
265
+ * @method
266
+ * @param {string[]} methods
267
+ * @public
268
+ */
269
+ setEnabledAuthMethods( methods ) {
270
+ this.#enabledAuthMethods = Array.isArray( methods ) ? [ ...methods ] : [];
271
+ }
272
+
273
+ /**
274
+ * Optional HTML transformation hook.
275
+ * <br/>
276
+ * NOTE: Override in subclasses to add nonces or other dynamic data to outgoing HTML.
277
+ *
278
+ * @method
279
+ * @param {string} html
280
+ * @param {Object} [options]
281
+ * @param {string} [options.csrfToken] Optional CSRF token to inject into the HTML.
282
+ * @param {boolean} [options.isHome] Optional flag to indicate whether the requested route is the home page.
283
+ * @param {string} [options.nonce] Optional CSP nonce to inject into inline scripts/styles.
284
+ * @param {string} [options.title] Optional title to replace the placeholder in the HTML.
285
+ * @returns {Promise<string>}
286
+ * @virtual
287
+ * @public
288
+ */
289
+ transformHtml( html, options = {} ) {
290
+ return new Promise( ( resolve, reject ) => {
291
+ let transformedHtml = String( html );
292
+
293
+ // Insert nonce in all placeholder locations. If nonce is not provided or is invalid, this will use an empty string instead to remove the placeholder:
294
+ const nonce = ( typeof options?.nonce === "string" && RE_CSP_NONCE.test( options?.nonce ) ) ? options?.nonce : "";
295
+ transformedHtml = transformedHtml.replaceAll( RE_NONCE_ATTR, nonce );
296
+ if ( options.isHome ) {
297
+ let htmxConfig = {
298
+ inlineScriptNonce: nonce,
299
+ inlineStyleNonce: nonce,
300
+ allowEval: false,
301
+ refreshOnHistoryMiss: true,
302
+ historyCacheSize: 0
303
+ };
304
+ transformedHtml = transformedHtml.replace( RE_HTMX_CONFIG, JSON.stringify( htmxConfig ) );
305
+ }
306
+
307
+ const csrfToken = ( typeof options?.csrfToken === "string" ) ? options?.csrfToken : "";
308
+ transformedHtml = transformedHtml.replaceAll( RE_CSRF_ATTR, csrfToken );
309
+
310
+ transformedHtml = transformedHtml.replace( "{ti-title-placeholder}", options.title || "" );
311
+
312
+ // Gate login-page OpenID provider buttons to the effective enabled auth methods (no-op on other fragments).
313
+ transformedHtml = applyAuthMethodVisibility( transformedHtml, this.#enabledAuthMethods );
314
+
315
+ resolve( transformedHtml );
316
+ } );
317
+ }
318
+
319
+ /**
320
+ * Used to assemble the complete HTML view for the requested route, including nested HTML fragments.
321
+ *
322
+ * @method
323
+ * @param {TiSession} session
324
+ * @param {string[]} staticContentPaths
325
+ * @param {string} route
326
+ * @param {Object} [options]
327
+ * @param {string} [options.csrfToken] Optional CSRF token to inject into the HTML.
328
+ * @param {boolean} [options.isPartial] Optional flag to indicate whether the requested route is a partial load of a fragment.
329
+ * @param {string} [options.view] Optional view name to load within this route.
330
+ * @param {string} [options.nonce] Optional CSP nonce to inject into inline scripts/styles.
331
+ * @returns {Promise<string>}
332
+ * @public
333
+ */
334
+ assembleHtmlView( session, staticContentPaths, route, options = {} ) {
335
+ return new Promise( ( resolve, reject ) => {
336
+ let fragment;
337
+ let getHtmlPromises = [];
338
+ let localOptions = ( options && typeof options === "object" ) ? { ...options } : {};
339
+
340
+ if ( route === "/" ) {
341
+ fragment = this.#fragments[ 'home' ];
342
+ getHtmlPromises.push( this.#getHtmlFragment( session, staticContentPaths, fragment, { ...localOptions, isHome: true } ) );
343
+ } else if ( route === "/app/error" ) {
344
+ // TODO: This endpoint is for testing purposes only. Remove later.
345
+ return reject( exceptions.raise( exceptions.exceptionCode.E_WEB_INVALID_REQUEST_METHOD ) );
346
+ } else if ( route === "/app" || route === "/app/enter" ) {
347
+ fragment = ( session && session.user ) ? this.#fragments[ 'application-main' ] : this.#fragments[ 'login' ];
348
+ getHtmlPromises.push( this.#getHtmlFragment( session, staticContentPaths, fragment, localOptions ) );
349
+ } else if ( route === "/not-found" ) {
350
+ getHtmlPromises.push( this.#getHtmlFragment( session, staticContentPaths, this.#fragments[ 'home' ], { ...localOptions, isHome: true, title: this.#fragments[ 'not-found' ].title } ) );
351
+ getHtmlPromises.push( this.#getHtmlFragment( session, staticContentPaths, this.#fragments[ 'not-found' ], localOptions ) );
352
+ } else {
353
+ fragment = this.#fragments[ options.view ];
354
+ if ( !fragment ) {
355
+ // Abort execution if the requested view is not found:
356
+ return reject( exceptions.raise( exceptions.exceptionCode.E_WEB_INVALID_REQUEST_URI ) );
357
+ } else {
358
+ // This handles application refreshes from nested frames:
359
+ if ( options.isPartial !== true ) {
360
+ getHtmlPromises.push( this.#getHtmlFragment( session, staticContentPaths, this.#fragments[ 'home' ], {
361
+ ...localOptions,
362
+ isHome: true,
363
+ title: fragment.title
364
+ } ) );
365
+ getHtmlPromises.push( this.#getHtmlFragment( session, staticContentPaths, this.#fragments[ 'application-main' ], localOptions ) );
366
+ }
367
+ getHtmlPromises.push( this.#getHtmlFragment( session, staticContentPaths, fragment, localOptions ) );
368
+ }
369
+ }
370
+
371
+ Promise.all( getHtmlPromises ).then( ( filesData ) => {
372
+ let assembledHtml = undefined;
373
+ filesData.forEach( ( fileData ) => {
374
+ // There should always be at most one ti-nested-frame-placeholder element in each HTML fragment:
375
+ assembledHtml = ( assembledHtml ) ? this.#replacePlaceholderElement( assembledHtml, TI_NESTED_FRAME_PLACEHOLDER, fileData ) : fileData;
376
+ } );
377
+ resolve( assembledHtml );
378
+ } ).catch( ( error ) => {
379
+ reject( exceptions.raise( error ) );
380
+ } );
381
+ } );
382
+ }
383
+
384
+ /**
385
+ * Used to process a request for a data resource.
386
+ *
387
+ * @method
388
+ * @param {TiSession} session
389
+ * @param {string} view
390
+ * @param {Object} [options]
391
+ * @returns {Promise<Object>}
392
+ * @virtual
393
+ * @public
394
+ */
395
+ processDataRequest( session, view, options = {} ) {
396
+ return new Promise( ( resolve, reject ) => {
397
+ if ( view === "config" ) {
398
+ resolve( {
399
+ labels: localization.getAllLabels( session?.language ),
400
+ auth: {
401
+ isAuthenticated: Boolean( session && session.user )
402
+ }
403
+ } );
404
+ } else {
405
+ reject( exceptions.raise( exceptions.exceptionCode.E_WEB_INVALID_REQUEST_URI, { view: view } ) );
406
+ }
407
+ } );
408
+ }
409
+
410
+ /**
411
+ * Used to process an application service request.
412
+ *
413
+ * @method
414
+ * @param {TiSession} session
415
+ * @param {string} service
416
+ * @param {Object} params
417
+ * @returns {Promise<Object>}
418
+ * @virtual
419
+ * @public
420
+ */
421
+ processServiceRequest( session, service, params ) {
422
+ return new Promise( ( resolve, reject ) => {
423
+ reject( exceptions.raise( exceptions.exceptionCode.E_WEB_INVALID_REQUEST_URI, { service: service } ) );
424
+ } );
425
+ }
426
+
427
+ /**
428
+ * Used to verify whether the current user has access to the requested resource. The default implementation gates
429
+ * HTML fragments by their declared `roles`: a fragment registered via {@link TiWebAppManager#addFragment} with a
430
+ * `roles` array is served only to sessions holding at least one of those roles (see {@link addFragment}); a
431
+ * fragment with no `roles` is public to any authenticated user. This makes role-restricted screens unreachable by
432
+ * direct URL, not merely hidden in the UI. Override in subclasses only to implement additional/alternative checks.
433
+ *
434
+ * @method
435
+ * @virtual
436
+ * @param {TiSession} session
437
+ * @param {Object} resource The fragment descriptor; its optional `resource.roles` lists the roles permitted to load it.
438
+ * @returns {Promise}
439
+ * @exception {TiException.E_SEC_UNAUTHORIZED_ACCESS} (403) When the session holds none of the fragment's required roles.
440
+ * @public
441
+ */
442
+ verifyAccess( session, resource ) {
443
+ return new Promise( ( resolve, reject ) => {
444
+ const requiredRoles = ( resource && resource.roles ) ? resource.roles : null;
445
+ const userRoles = ( session && session.user && session.user.roles ) || [];
446
+ if ( authorization.isAccessAllowed( requiredRoles, userRoles ) ) {
447
+ return resolve();
448
+ }
449
+ reject( exceptions.raise( exceptions.exceptionCode.E_SEC_UNAUTHORIZED_ACCESS, null, exceptions.httpCode.C_403 ) );
450
+ } );
451
+ }
452
+
453
+ /* Private interface */
454
+
455
+ /**
456
+ * Returns the HTML fragment for the requested route.
457
+ *
458
+ * @method
459
+ * @param {TiSession} session
460
+ * @param {string[]} staticContentPaths
461
+ * @param {Object} fragment
462
+ * @param {Object} [options]
463
+ * @param {string} [options.csrfToken] Optional CSRF token to inject into the HTML.
464
+ * @param {boolean} [options.isHome] Optional flag to indicate whether the requested route is the home page.
465
+ * @param {string} [options.nonce] Optional CSP nonce to inject into inline scripts/styles.
466
+ * @returns {Promise<string>}
467
+ */
468
+ #getHtmlFragment( session, staticContentPaths, fragment, options = {} ) {
469
+ return new Promise( ( resolve, reject ) => {
470
+ this.verifyAccess( session, fragment ).then( () => {
471
+ return this.#locateStaticFile( staticContentPaths, fragment.path );
472
+ } ).then( ( fileData ) => {
473
+ return this.#replaceComponentPlaceholders( fileData, staticContentPaths, fragment.components );
474
+ } ).then( ( fileData ) => {
475
+ return this.transformHtml( fileData, { ...options, title: fragment.title || options.title } );
476
+ } ).then( ( fileData ) => {
477
+ resolve( fileData );
478
+ } ).catch( ( error ) => {
479
+ reject( exceptions.raise( error ) );
480
+ } );
481
+ } );
482
+ }
483
+
484
+ /**
485
+ * Attempts to locate the requested static file in the provided static content paths.
486
+ *
487
+ * @method
488
+ * @param {string[]} staticContentPaths A list of directories to search for static content. If sent as expected by the web server, the first item in the array should be the system default path.
489
+ * @param {string} filePath Relative path to the static file to locate.
490
+ * @returns {Promise<string>}
491
+ */
492
+ #locateStaticFile( staticContentPaths, filePath ) {
493
+ return new Promise( ( resolve, reject ) => {
494
+ if ( this.#staticFileCacheEnabled === true && this.#staticFileCache[ filePath ] !== undefined ) {
495
+ resolve( this.#staticFileCache[ filePath ] );
496
+ } else {
497
+ let fullFilePath;
498
+ // Search for the file in the static content paths in reverse order so that the default system path is checked last. This will ensure that
499
+ // any fragment overrides are loaded first (i.e., fragments with the same relative path):
500
+ for ( let idx = staticContentPaths.length - 1; idx >= 0; idx-- ) {
501
+ const staticContentPath = staticContentPaths[ idx ];
502
+ let potentialFilePath = path.join( staticContentPath, filePath );
503
+ if ( fs.existsSync( potentialFilePath ) ) {
504
+ fullFilePath = potentialFilePath;
505
+ break;
506
+ }
507
+ }
508
+
509
+ if ( fullFilePath !== undefined ) {
510
+ fs.promises.readFile( fullFilePath, "utf8" ).then( ( fileData ) => {
511
+ if ( this.#staticFileCacheEnabled === true ) {
512
+ this.#staticFileCache[ filePath ] = fileData;
513
+ }
514
+ resolve( fileData );
515
+ } ).catch( ( error ) => {
516
+ reject( exceptions.raise( error ) );
517
+ } );
518
+ } else {
519
+ reject( exceptions.raise( exceptions.exceptionCode.E_WEB_INVALID_REQUEST_URI ) );
520
+ }
521
+ }
522
+ } );
523
+ }
524
+
525
+ /**
526
+ * Used to replace the component placeholders in the HTML with the actual component HTML.
527
+ *
528
+ * @method
529
+ * @param {string} html
530
+ * @param {string[]} staticContentPaths
531
+ * @param {Array<string>} components
532
+ * @returns {Promise<string>}
533
+ */
534
+ #replaceComponentPlaceholders( html, staticContentPaths, components ) {
535
+ return new Promise( ( resolve, reject ) => {
536
+ if ( components === undefined || components.length === 0 ) {
537
+ resolve( html );
538
+ } else {
539
+ let transformedHtml = String( html );
540
+ let promises = [];
541
+ const componentData = {};
542
+ tools.arrayUniques( components ).forEach( ( component ) => {
543
+ promises.push( this.#locateStaticFile( staticContentPaths, `fragments/components/${ component }.html` ).then( ( fileData ) => {
544
+ componentData[ component ] = fileData;
545
+ } ) );
546
+ } );
547
+
548
+ Promise.all( promises ).then( () => {
549
+ components.forEach( ( component ) => {
550
+ transformedHtml = this.#replacePlaceholderElement( transformedHtml, `ti-${ component }-placeholder`, componentData[ component ] );
551
+ } );
552
+ resolve( transformedHtml );
553
+ } ).catch( ( error ) => {
554
+ reject( exceptions.raise( error ) );
555
+ } );
556
+ }
557
+ } );
558
+ }
559
+
560
+ /**
561
+ * Used to replace a placeholder element in the HTML with the provided replacement.
562
+ * <br/>
563
+ * The placeholder's attributes are exposed to the replacement HTML as `{ti-<attr-name>}` tokens, allowing the
564
+ * component template to consume initial data without changing its `x-data` factory.
565
+ * <br/>
566
+ * If the replacement HTML contains a `<ti-slot></ti-slot>` (or self-closing `<ti-slot/>`) marker, the placeholder's
567
+ * inner content replaces it precisely; otherwise, inner content is appended before the replacement's last closing
568
+ * tag (legacy behaviour) so existing components keep working.
569
+ *
570
+ * @method
571
+ * @param {string} html
572
+ * @param {string} tagName
573
+ * @param {string} replacement
574
+ * @returns {string}
575
+ */
576
+ #replacePlaceholderElement( html, tagName, replacement ) {
577
+ const start = html.indexOf( `<${ tagName }` );
578
+ if ( start === -1 ) {
579
+ return html;
580
+ }
581
+ const gt = html.indexOf( ">", start );
582
+ if ( gt === -1 ) {
583
+ return html;
584
+ }
585
+ // Tolerate whitespace(s) before '/>' and attributes on the tag:
586
+ let p = gt - 1;
587
+ while ( p > start && /\s/.test( html[ p ] ) ) p--;
588
+ const isSelfClosing = html[ p ] === "/";
589
+ let end;
590
+ let inner = "";
591
+ if ( isSelfClosing ) {
592
+ end = gt + 1;
593
+ } else {
594
+ const close = `</${ tagName }>`;
595
+ end = html.indexOf( close, gt + 1 );
596
+ if ( end === -1 ) {
597
+ return html;
598
+ }
599
+ inner = html.slice( gt + 1, end );
600
+ end += close.length;
601
+ }
602
+
603
+ // Substitute the placeholder's attributes as `{ti-<name>}` tokens inside the replacement HTML:
604
+ const placeholderAttributes = this.#parsePlaceholderAttributes( html.slice( start, gt + 1 ) );
605
+ let processedReplacement = replacement;
606
+ Object.keys( placeholderAttributes ).forEach( ( name ) => {
607
+ const value = placeholderAttributes[ name ];
608
+ const token = `{ti-${ name }}`;
609
+ // Use split/join for a literal replaceAll without regex escaping concerns:
610
+ processedReplacement = processedReplacement.split( token ).join( value );
611
+ } );
612
+
613
+ let replacementWithInner = processedReplacement;
614
+ const slotMatch = processedReplacement.match( /<ti-slot\b[^>]*>[\s\S]*?<\/ti-slot>|<ti-slot\b[^>]*\/>/ );
615
+ if ( slotMatch ) {
616
+ // If a slot marker exists, the placeholder's inner content (when present) replaces it. When inner is
617
+ // empty, the slot's own default content (between <ti-slot> and </ti-slot>) is kept by unwrapping it:
618
+ if ( inner ) {
619
+ replacementWithInner = processedReplacement.replace( slotMatch[ 0 ], inner );
620
+ } else {
621
+ replacementWithInner = processedReplacement.replace( slotMatch[ 0 ], ( match ) => {
622
+ const defaultMatch = match.match( /<ti-slot\b[^>]*>([\s\S]*?)<\/ti-slot>/ );
623
+ return defaultMatch ? defaultMatch[ 1 ] : "";
624
+ } );
625
+ }
626
+ } else if ( inner ) {
627
+ const insertAt = processedReplacement.lastIndexOf( "</" );
628
+ if ( insertAt !== -1 ) {
629
+ replacementWithInner = processedReplacement.slice( 0, insertAt ) + inner + processedReplacement.slice( insertAt );
630
+ } else {
631
+ replacementWithInner = processedReplacement + inner;
632
+ }
633
+ }
634
+
635
+ return html.slice( 0, start ) + replacementWithInner + html.slice( end );
636
+ }
637
+
638
+ /**
639
+ * Parses the attribute name/value pairs declared on a placeholder element's opening tag.
640
+ *
641
+ * @method
642
+ * @param {string} openingTag The full opening tag text, e.g. `<ti-foo-placeholder bar="baz">`.
643
+ * @returns {Object<string, string>}
644
+ */
645
+ #parsePlaceholderAttributes( openingTag ) {
646
+ const attributes = {};
647
+ // Strip the element name and the surrounding angle brackets so only the attribute string remains:
648
+ const trimmed = openingTag.replace( /^<[^\s>/]+/, "" ).replace( /\/?>\s*$/, "" );
649
+ const regex = /([a-zA-Z_][\w:.-]*)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/g;
650
+ let match;
651
+ while ( ( match = regex.exec( trimmed ) ) !== null ) {
652
+ attributes[ match[ 1 ] ] = match[ 2 ] ?? match[ 3 ] ?? match[ 4 ] ?? "";
653
+ }
654
+ return attributes;
655
+ }
656
+
657
+ }
658
+
659
+ module.exports = TiWebAppManager;
660
+ TiWebAppManager.applyAuthMethodVisibility = applyAuthMethodVisibility;