@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
package/bin/web-server.js CHANGED
@@ -1,937 +1,936 @@
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 ServiceConsumer = require( "@ti-engine/core/service-consumer" );
10
- const exceptions = require( "@ti-engine/core/exceptions" );
11
- const logger = require( "@ti-engine/core/logger" );
12
- const { randomBytes } = require( "node:crypto" );
13
- const path = require( "node:path" );
14
- const fs = require( "node:fs" );
15
- const _ = require( "lodash" );
16
- const express = require( "express" );
17
- const helmet = require( "helmet" );
18
- const session = require( "express-session" );
19
- const cookieParser = require( "cookie-parser" );
20
- const webHandlers = require( "#web-handlers" );
21
- const SessionStore = require( "#session-store" );
22
- const AuthManager = require( "#auth-manager" );
23
- const authMethod = require( "#auth-manager" ).authMethod;
24
- const authorization = require( "#authorization" );
25
- const adminConfigHandlers = require( "#admin-config-handlers" );
26
- const configService = require( "#config-service" );
27
- const applyWebConfigEnvOverrides = require( "#web-config-env" );
28
-
29
- /** @typedef {import("node:http").Server} NodeServer */
30
-
31
- /**
32
- * @typedef {ServiceConfiguration} TiWebServiceConfiguration
33
- * @property {ApiConfig} api
34
- * @property {TiWebApplicationConfig} application
35
- * @property {SettingsAuth} auth
36
- * @property {SettingsCookies} cookies
37
- * @property {string} host
38
- * @property {TiLocalizationLanguage} language
39
- * @property {number} port
40
- * @property {string} publicPath
41
- * @property {number} requestTimeout
42
- * @property {SettingsStaticCache} staticCache
43
- * @property {string} tlsCertPath
44
- * @property {string} tlsKeyPath
45
- * @property {boolean} useTLS
46
- */
47
-
48
- /**
49
- * @typedef {Object} TiWebApplicationConfig
50
- * @property {string} classPath
51
- */
52
-
53
- /**
54
- * @typedef {Object} ApiConfig
55
- * @property {boolean} endpointEnabled
56
- * @property {ApiInventory} inventory
57
- * @property {number} requestTimeout
58
- */
59
-
60
- /**
61
- * @typedef {Object} SettingsAuth
62
- * @property {string[]} enabledMethods
63
- * @property {Object} local
64
- * @property {Object} oauth2
65
- * @property {SettingsOAuth2Client} [oauth2.azure]
66
- * @property {SettingsOAuth2Client} [oauth2.google]
67
- */
68
-
69
- /**
70
- * @typedef {Object} SettingsOAuth2Client
71
- * @property {string} [clientID]
72
- * @property {string} [clientSecret]
73
- * @property {string} [callbackUrl]
74
- * @property {string} [discoveryUrl]
75
- * @property {boolean} [isPublic]
76
- * @property {TiTokenEndpointAuthMethod} [tokenEndpointAuthMethod]
77
- */
78
-
79
- /**
80
- * @typedef {Object} SettingsStaticCache
81
- * @property {number} maxAge The `max-age` for `/static` responses, in SECONDS (not a duration string). `0` means every use is revalidated.
82
- * @property {boolean} immutable Whether to add `immutable`. Only correct when the `/static` filenames are content-addressed.
83
- * @property {string[]} immutablePaths Path prefixes under `/static` that are served long-lived and `immutable` regardless of the two settings above.
84
- */
85
-
86
- /**
87
- * @typedef {Object} SettingsCookies
88
- * @property {string} secret
89
- * @property {string} path
90
- * @property {boolean} httpOnly
91
- * @property {"lax"|"strict"|"none"} sameSite
92
- * @property {number} maxAge
93
- */
94
-
95
- /**
96
- * @typedef {Record<string, Record<string, ServiceAddress>>} ApiInventory
97
- */
98
-
99
- const webServerConfig = require( "#web-server-config" );
100
-
101
- /**
102
- * Default unprotected static-asset route matchers. The path segments are matched with `(?:[^/]+\/)*` rather than
103
- * `(?:.+\/)*`: the inner `[^/]+` cannot also consume the "/" delimiter, so the pattern is unambiguous and matches
104
- * in linear time. The previous `.+` form was ambiguous and backtracked exponentially on hostile request paths such
105
- * as `/static/a/a/…/a/x` (no trailing extension) and these matchers run against the raw request path in
106
- * {@link TiWebServer#isUnprotectedRoute} BEFORE authentication, so that was a pre-auth denial-of-service vector
107
- * (CodeQL js/redos). The matched language for realistic asset paths is unchanged.
108
- *
109
- * @type {RegExp}
110
- */
111
- const RE_STATIC_UNPROTECTED = /^\/static\/(?:[^/]+\/)*[^/]+\.[^/]+$/i;
112
-
113
- /**
114
- * Default unprotected `/.well-known/` route matcher. See {@link RE_STATIC_UNPROTECTED} for the ReDoS rationale.
115
- *
116
- * @type {RegExp}
117
- */
118
- const RE_WELL_KNOWN_UNPROTECTED = /^\/\.well-known\/(?:[^/]+\/)*[^/]+\.[^/]+$/i;
119
-
120
- /**
121
- * A web server microservice based on the ti-engine.
122
- * <br/>
123
- * Note: The web server is fully functional and already comes with all the necessary fundamentals and security features. However, it is designed to be extended
124
- * with custom logic and functionality to fit your specific needs. Here is a list of methods that you can override to customize the web server behavior:
125
- * - {@link TiWebServer#defineWebApplicationRoutes} Override this to define custom web application routes. Remember to call the base method if you want to preserve the default behavior as well (recommended).
126
- * - {@link TiWebServer#defineUnprotectedRoutes} Override this to define unprotected routes. Remember to call the base method if you want to preserve the default behavior as well (recommended).
127
- * - {@link TiWebServer#verifySession} Override this to implement custom session verification logic.
128
- *
129
- * @class TiWebServer
130
- * @extends ServiceConsumer
131
- * @public
132
- */
133
- class TiWebServer extends ServiceConsumer {
134
-
135
- #webServer;
136
- #netServer;
137
- #serverUrl = "";
138
- #isShuttingDown = false;
139
- #staticContentPaths = [];
140
- #allowedHosts = [];
141
- #unprotectedRoutes = [];
142
- #webAppManager;
143
- #authManager;
144
-
145
- /**
146
- * @constructor
147
- * @param {string} serviceDomainName The service domain name for this service instance.
148
- * @param {TiWebServiceConfiguration} serviceConfig The JSON configuration for this service. Note that the configuration provided will be merged with the default web server configuration, and it will override any conflicting properties.
149
- * @throws {TiException.E_GEN_JS_INTERNAL_ERROR} If the web application manager cannot be loaded.
150
- */
151
- constructor( serviceDomainName, serviceConfig ) {
152
- super( serviceDomainName, applyWebConfigEnvOverrides( _.merge( {}, webServerConfig, ( _.isObjectLike( serviceConfig ) ) ? serviceConfig : {} ) ) );
153
-
154
- // Include the current host in the list of allowed hosts:
155
- this.#allowedHosts.push( this.serviceConfig.host );
156
-
157
- // Add the default and custom public paths to the list of static content:
158
- this.#staticContentPaths.push( path.join( __dirname, "static" ) );
159
- let customStaticContentPath = path.normalize( path.isAbsolute( this.serviceConfig.publicPath ) ? this.serviceConfig.publicPath : path.join( process.cwd(), this.serviceConfig.publicPath ) );
160
- if ( fs.existsSync( customStaticContentPath ) === false ) {
161
- logger.log( `Public path '${ customStaticContentPath }' does not exist. Static routes will resolve with 404 until path is created.`, logger.logSeverity.WARNING );
162
- } else {
163
- this.#staticContentPaths.push( customStaticContentPath );
164
- }
165
-
166
- this.#authManager = new AuthManager( this.serviceConfig.auth );
167
-
168
- // If there is a web application configuration, create the web application manager:
169
- if ( this.serviceConfig.application ) {
170
- try {
171
- const webApplicationConstructor = require( path.join( process.cwd(), this.serviceConfig.application.classPath ) );
172
- this.#webAppManager = new webApplicationConstructor();
173
- } catch ( error ) {
174
- logger.log( `Failed to load web application manager from '${ this.serviceConfig.application.classPath }'`, logger.logSeverity.ERROR, error );
175
- throw exceptions.raise( error );
176
- }
177
- }
178
- }
179
-
180
- /* Public interface */
181
-
182
- /**
183
- * Property returning the service configuration JSON.
184
- *
185
- * @property
186
- * @returns {TiWebServiceConfiguration}
187
- * @override
188
- * @public
189
- */
190
- get serviceConfig() {
191
- return super.serviceConfig;
192
- }
193
-
194
- /**
195
- * Property returning if the web server is currently shutting down.
196
- *
197
- * @property
198
- * @returns {boolean}
199
- * @public
200
- */
201
- get isShuttingDown() {
202
- return this.#isShuttingDown;
203
- }
204
-
205
- /**
206
- * Property returning the list of static content directories.
207
- *
208
- * @property
209
- * @returns {string[]}
210
- * @public
211
- */
212
- get staticContentPaths() {
213
- return this.#staticContentPaths;
214
- }
215
-
216
- /**
217
- * Property returning the server URL.
218
- *
219
- * @property
220
- * @returns {string}
221
- * @public
222
- */
223
- get serverUrl() {
224
- return this.#serverUrl;
225
- }
226
-
227
- /**
228
- * Property returning the {@link TiWebAppManager} instance.
229
- *
230
- * @property
231
- * @returns {TiWebAppManager}
232
- * @public
233
- */
234
- get webAppManager() {
235
- return this.#webAppManager;
236
- }
237
-
238
- /**
239
- * Starts the web server.
240
- *
241
- * @method
242
- * @returns {Promise}
243
- * @override
244
- * @public
245
- */
246
- onStart() {
247
- return new Promise( ( resolve, reject ) => {
248
- super.onStart().then( () => {
249
- // Create and configure the web server:
250
- this.#webServer = express();
251
- this.#webServer.set( "trust proxy", true );
252
-
253
- // Create and configure the net server for HTTPS if enabled in the service config:
254
- let netServerOptions = {};
255
- const timeoutCandidates = [
256
- this.serviceConfig.api.requestTimeout,
257
- this.serviceConfig.requestTimeout
258
- ].filter( ( value ) => Number.isFinite( value ) );
259
- const resolvedRequestTimeout = timeoutCandidates.length ? Math.max( ...timeoutCandidates ) : undefined;
260
- if ( this.serviceConfig.useTLS === true ) {
261
- if ( !this.serviceConfig.tlsKeyPath || !this.serviceConfig.tlsCertPath ) {
262
- // Abort initialization if there is something wrong with the TLS key or cert paths:
263
- return reject( exceptions.raise( exceptions.exceptionCode.E_GEN_INVALID_ARGUMENT_TYPE, {
264
- tlsKeyPath: this.serviceConfig.tlsKeyPath,
265
- tlsCertPath: this.serviceConfig.tlsCertPath
266
- }, exceptions.httpCode.C_500 ) );
267
- }
268
- netServerOptions.key = fs.readFileSync( path.join( process.cwd(), this.serviceConfig.tlsKeyPath ) );
269
- netServerOptions.cert = fs.readFileSync( path.join( process.cwd(), this.serviceConfig.tlsCertPath ) );
270
-
271
- this.#webServer.use( webHandlers.httpRedirectHandler( this ) );
272
- this.#netServer = require( "node:https" ).createServer( netServerOptions, this.#webServer );
273
- } else {
274
- this.#netServer = require( "node:http" ).createServer( netServerOptions, this.#webServer );
275
- }
276
- if ( Number.isFinite( resolvedRequestTimeout ) ) {
277
- this.#netServer.requestTimeout = resolvedRequestTimeout;
278
- this.#netServer.headersTimeout = resolvedRequestTimeout + 100;
279
- if ( typeof this.#netServer.keepAliveTimeout === "number" ) {
280
- this.#netServer.keepAliveTimeout = resolvedRequestTimeout + 1000;
281
- }
282
- }
283
-
284
- // Set up security and session middlewares first:
285
- this.#webServer.use( webHandlers.nonceGenerationHandler() );
286
- // Helmet's built-in Content-Security-Policy is intentionally disabled here because a per-request,
287
- // nonce-based CSP is enforced on the very next line by webHandlers.cspHeaderHandler() (see
288
- // components/web-handlers.js) — Helmet's static config cannot express per-response nonces. Every other
289
- // Helmet header (HSTS, X-Content-Type-Options, X-Frame-Options, …) still applies. This is a deliberate
290
- // architecture, not missing CSP; do not enable Helmet's static CSP here, as that would drop the nonce.
291
- // codeql[js/insecure-helmet-configuration]
292
- this.#webServer.use( helmet( { contentSecurityPolicy: false } ) );
293
- this.#webServer.use( webHandlers.cspHeaderHandler() );
294
- this.#webServer.use( express.json( { limit: "1mb" } ) );
295
- this.#webServer.use( express.urlencoded( { extended: false, limit: "100kb" } ) );
296
- this.#webServer.use( cookieParser() );
297
- this.#webServer.use( session( {
298
- secret: this.serviceConfig.cookies.secret || randomBytes( 32 ).toString( "base64" ),
299
- resave: false,
300
- saveUninitialized: false,
301
- cookie: {
302
- path: this.serviceConfig.cookies.path,
303
- httpOnly: this.serviceConfig.cookies.httpOnly,
304
- secure: "auto",
305
- sameSite: this.serviceConfig.cookies.sameSite,
306
- maxAge: this.serviceConfig.cookies.maxAge
307
- },
308
- unset: "destroy",
309
- store: new SessionStore()
310
- } ) );
311
- this.#webServer.use( webHandlers.csrfInitHandler( this ) );
312
- this.#webServer.use( webHandlers.originRefererValidationHandler( this ) );
313
- this.#webServer.use( webHandlers.csrfProtectionHandler() );
314
-
315
- // Set up the web server routes:
316
- this.#webServer.use( webHandlers.onShutDownHandler( this ) );
317
- this.#webServer.use( webHandlers.resourceProtectionHandler( this ) );
318
- this.#webServer.use( "/.well-known", express.static( path.join( this.#staticContentPaths[ 0 ], ".well-known" ), { dotfiles: "allow" } ) );
319
-
320
- // Static content routes are registered in reverse order to ensure that custom assets can override the default ones and be served first:
321
- const staticCachePolicy = TiWebServer.resolveStaticCachePolicy( this.serviceConfig.staticCache );
322
- staticCachePolicy.warnings.forEach( ( warning ) => logger.log( warning, logger.logSeverity.WARNING ) );
323
- _.forEachRight( this.#staticContentPaths, ( staticContentPath ) => {
324
- // `Cache-Control` is written per file rather than through express.static's `maxAge`/`immutable`
325
- // options, because the policy is not uniform across the tree (see resolveStaticCachePolicy). A
326
- // header set here wins: `send` emits its "headers" event BEFORE its own `Cache-Control` block,
327
- // which then skips a header that is already present. `ETag`/`Last-Modified` are still added by
328
- // `send`, so the revalidating default costs a conditional request answered with a 304, not a
329
- // re-download.
330
- this.#webServer.use( "/static", express.static( staticContentPath, {
331
- setHeaders: ( response, filePath ) => {
332
- response.setHeader( "Cache-Control", TiWebServer.staticCacheControlFor( staticContentPath, filePath, staticCachePolicy ) );
333
- }
334
- } ) );
335
- } );
336
-
337
- // Set up the web application routes:
338
- this.defineWebApplicationRoutes();
339
-
340
- // API service proxy route (protected by auth middleware):
341
- if ( this.serviceConfig.api.endpointEnabled === true ) {
342
- this.#webServer.post( "/service/:version/:name", webHandlers.serviceCallHandler( this ) );
343
- }
344
-
345
- // Set up error handling middleware:
346
- this.#webServer.all( "*splat", webHandlers.invalidRouteHandler() );
347
- this.#webServer.use( webHandlers.defaultErrorHandler() );
348
-
349
- // Set up the unprotected routes:
350
- this.defineUnprotectedRoutes();
351
-
352
- return this.#authManager.initialize();
353
- } ).then( () => {
354
- // Hand the web application manager the effective enabled auth methods (after any unconfigured OpenID
355
- // providers were dropped) so the login page only renders providers a user can actually complete.
356
- if ( this.#webAppManager && typeof this.#webAppManager.setEnabledAuthMethods === "function" ) {
357
- this.#webAppManager.setEnabledAuthMethods( this.#authManager.getEnabledMethods() );
358
- }
359
- return this.#beginListening( this.#netServer, this.serviceConfig.port, this.serviceConfig.host );
360
- } ).then( ( server ) => {
361
- if ( server.listening === true ) {
362
- this.#serverUrl = `http${ this.serviceConfig.useTLS === true ? "s" : "" }://${ server.address().address }:${ server.address().port }`;
363
- logger.log( `Web server started at address '${ this.#serverUrl }' within instance '${ ServiceConsumer.instanceID }'.`, logger.logSeverity.NOTICE );
364
- } else {
365
- logger.log( `Web server is not listening for requests after startup within instance '${ ServiceConsumer.instanceID }'.`, logger.logSeverity.WARNING );
366
- }
367
- resolve();
368
- } ).catch( ( error ) => {
369
- logger.log( `Error while trying to start web server within instance '${ ServiceConsumer.instanceID }'!`, logger.logSeverity.ERROR, error );
370
- reject( exceptions.raise( error ) );
371
- } );
372
- } );
373
- }
374
-
375
- /**
376
- * Shuts down the web server.
377
- *
378
- * @method
379
- * @returns {Promise}
380
- * @override
381
- * @public
382
- */
383
- onStop() {
384
- return new Promise( ( resolve, reject ) => {
385
- this.#isShuttingDown = true;
386
-
387
- super.onStop().then( () => {
388
- return this.#endListening( this.#netServer );
389
- } ).then( () => {
390
- logger.log( `Web server stopped successfully.`, logger.logSeverity.NOTICE );
391
- resolve();
392
- } ).catch( ( error ) => {
393
- reject( exceptions.raise( error ) );
394
- } );
395
- } );
396
- }
397
-
398
- /**
399
- * Used to report health status of the service instance for external monitoring.
400
- * This is a scheduled job that will be executed at SERVICE_HEALTH_CHECK_INTERVAL time.
401
- *
402
- * @method
403
- * @override
404
- * @public
405
- */
406
- reportHealthy() {
407
- super.reportHealthy();
408
- }
409
-
410
- /**
411
- * Used to verify the session of a request.
412
- *
413
- * @method
414
- * @param {TiSession} session
415
- * @returns {boolean}
416
- * @public
417
- */
418
- verifySession( session ) {
419
- // TODO: Implement this!
420
- return Boolean( session && session.user );
421
- }
422
-
423
- /**
424
- * Hook for the application to augment the freshly-authenticated session (e.g. derive domain roles from an
425
- * identity store or the org chart). Runs synchronously, once per login, before the framework's additive `admin`
426
- * role is applied. The default is a no-op. Any test-user role injection is an override of whatever the app derives.
427
- *
428
- * @method
429
- * @virtual
430
- * @param {TiSession} session
431
- * @param {Object} [request] Optional Express request object that can be used to read body/cookies/query data.
432
- * @returns {TiSession}
433
- * @public
434
- */
435
- augmentSession( session, request ) {
436
- return session;
437
- }
438
-
439
- /**
440
- * Used to authenticate a user via the specified auth method.
441
- *
442
- * @method
443
- * @param {TiAuthMethod} authMethod
444
- * @param {Object} [authDetails={}]
445
- * @returns {Promise}
446
- * @public
447
- */
448
- authenticate( authMethod, authDetails = {} ) {
449
- return this.#authManager.authenticate( authMethod, authDetails );
450
- }
451
-
452
- /**
453
- * Used to set up user authorization according to the specified auth method.
454
- *
455
- * @method
456
- * @param {TiAuthMethod} authMethod
457
- * @param {URL} currentUrl
458
- * @param {Object} oidc
459
- * @returns {Promise<User>}
460
- * @public
461
- */
462
- authorize( authMethod, currentUrl, oidc ) {
463
- return this.#authManager.authorize( authMethod, currentUrl, oidc );
464
- }
465
-
466
- /**
467
- * Used to get a service mapping if such exists.
468
- *
469
- * @method
470
- * @param {string} serviceVersion
471
- * @param {string} serviceName
472
- * @returns {ServiceAddress}
473
- * @public
474
- */
475
- getServiceAddress( serviceVersion, serviceName ) {
476
- let serviceAddress = undefined;
477
- if ( this.serviceConfig.api && this.serviceConfig.api.inventory ) {
478
- serviceAddress = ( this.serviceConfig.api.inventory[ serviceVersion ] ) ? this.serviceConfig.api.inventory[ serviceVersion ][ serviceName ] : undefined;
479
- }
480
- return serviceAddress;
481
- }
482
-
483
- /**
484
- * Used to check if the specified hostname is allowed to access the web server.
485
- *
486
- * @method
487
- * @param {string} hostname
488
- * @returns {boolean}
489
- * @public
490
- */
491
- isAllowedHost( hostname ) {
492
- return this.#allowedHosts.includes( hostname );
493
- }
494
-
495
- /**
496
- * Used to check if the specified route is unprotected (i.e., does not require authentication). The default unprotected routes are:
497
- * - /
498
- * - /static/...
499
- * - /.well-known/...
500
- * - /not-found
501
- * - /app
502
- * - /app/enter
503
- * - /app/config
504
- * - /logout
505
- * - /login/:method
506
- * <br/>
507
- * NOTE: You can define custom unprotected routes by overriding the {@link TiWebServer#defineUnprotectedRoutes} method.
508
- *
509
- * @method
510
- * @param {string} route
511
- * @returns {boolean}
512
- * @public
513
- */
514
- isUnprotectedRoute( route ) {
515
- const pathOnly = String( route || "" ).split( "?" )[ 0 ];
516
- return TiWebServer.isRouteInList( this.#unprotectedRoutes, pathOnly );
517
- }
518
-
519
- /**
520
- * Used to define the web application routes.
521
- * <br/>
522
- * NOTE: Override this to define custom web application routes. Remember to call the base method if you want to preserve the default behavior as well.
523
- *
524
- * @method
525
- * @virtual
526
- * @public
527
- */
528
- defineWebApplicationRoutes() {
529
- this.#webServer.get( "/", webHandlers.webAppHandler( this ) );
530
- this.#webServer.get( "/not-found", webHandlers.webAppHandler( this ) );
531
- this.#webServer.get( "/app", webHandlers.webAppHandler( this ) );
532
- this.#webServer.get( "/app/:view", webHandlers.webAppHandler( this ) );
533
- this.#webServer.post( "/app/:service", webHandlers.webAppHandler( this ) );
534
- this.#webServer.get( "/login/:method", webHandlers.authenticationHandler( this ) );
535
- this.#webServer.post( "/login/:method", webHandlers.authenticationHandler( this ) );
536
- this.#webServer.post( "/logout", webHandlers.logoutHandler() );
537
- this.#webServer.get( "/health", webHandlers.healthHandler() );
538
- this.#webServer.get( "/me", webHandlers.userInformationHandler() );
539
- // NOTE: A callback is registered by its path, never by the configured value verbatim — that value is commonly
540
- // the absolute URL registered with the identity provider, which Express cannot parse as a route pattern.
541
- [ authMethod.OPENID_GOOGLE, authMethod.OPENID_AZURE ].forEach( ( method ) => {
542
- if ( this.#authManager.isAuthEnabled( method ) === true ) {
543
- const callbackPath = this.#authManager.getOAuth2CallbackPath( method );
544
- if ( callbackPath ) {
545
- this.#webServer.get( callbackPath, webHandlers.authorizedOAuth2CallbackHandler( this, method ) );
546
- } else {
547
- logger.log( `Authentication method '${ method }' is enabled but its callback URL yields no usable route path; its callback endpoint was not registered and sign-in through it will fail.`, logger.logSeverity.WARNING );
548
- }
549
- }
550
- } );
551
-
552
- // Admin configuration-management API. Gated by the admin role; these paths are not in the unprotected-routes
553
- // list, so they also inherit the server's global authentication + CSRF middleware.
554
- const requireAdmin = authorization.requireAdmin;
555
- const service = configService.instance;
556
- this.#webServer.get( "/admin/config/editors", requireAdmin, adminConfigHandlers.listEditors( service ) );
557
- this.#webServer.get( "/admin/config/editors/:editorKey", requireAdmin, adminConfigHandlers.composeView( service ) );
558
- this.#webServer.post( "/admin/config/editors/:editorKey", requireAdmin, adminConfigHandlers.saveEditorEdit( service ) );
559
- this.#webServer.get( "/admin/config/documents/:configKey", requireAdmin, adminConfigHandlers.getCurrent( service ) );
560
- this.#webServer.get( "/admin/config/documents/:configKey/history", requireAdmin, adminConfigHandlers.getHistory( service ) );
561
- this.#webServer.get( "/admin/config/changes", requireAdmin, adminConfigHandlers.listChanges( service ) );
562
- this.#webServer.get( "/admin/config/changes/:changeSetID", requireAdmin, adminConfigHandlers.getChange( service ) );
563
- this.#webServer.post( "/admin/config/changes/:changeSetID/restore", requireAdmin, adminConfigHandlers.restoreChangeSet( service ) );
564
- this.#webServer.get( "/admin/config/export", requireAdmin, adminConfigHandlers.exportBundle( service ) );
565
- }
566
-
567
- /**
568
- * Used to define the unprotected routes (i.e., routes that do not require authentication).
569
- * <br/>
570
- * NOTE: Override this to define custom unprotected routes. Remember to call the base method if you want to preserve the default behavior as well.
571
- *
572
- * @method
573
- * @virtual
574
- * @public
575
- */
576
- defineUnprotectedRoutes() {
577
- this.#unprotectedRoutes.push( "/" );
578
- this.#unprotectedRoutes.push( "/not-found" );
579
- this.#unprotectedRoutes.push( "/app" );
580
- this.#unprotectedRoutes.push( "/app/enter" );
581
- this.#unprotectedRoutes.push( "/app/config" );
582
- this.#unprotectedRoutes.push( /^\/login\/[^/]+$/i );
583
- this.#unprotectedRoutes.push( "/logout" );
584
- this.#unprotectedRoutes.push( "/health" );
585
- this.#unprotectedRoutes.push( RE_STATIC_UNPROTECTED );
586
- this.#unprotectedRoutes.push( RE_WELL_KNOWN_UNPROTECTED );
587
- }
588
-
589
- /**
590
- * Registers a custom application route on the underlying Express app.
591
- * <br/>
592
- * NOTE: Call this from a {@link TiWebServer#defineWebApplicationRoutes} override AFTER invoking the base method,
593
- * so the framework's own routes keep priority and any catch-all route you add resolves last (it will still be
594
- * registered before the framework's own `*splat` 404 handler). It is only valid once the Express app exists —
595
- * i.e., from within {@link TiWebServer#defineWebApplicationRoutes}, which {@link TiWebServer#onStart} invokes.
596
- *
597
- * @method
598
- * @param {string} method One of the supported routing verbs: get, post, put, patch, delete, options, head, all.
599
- * @param {string|RegExp} path The route path or pattern.
600
- * @param {...Function} handlers One or more Express route handlers/middleware.
601
- * @returns {TiWebServer} This instance, to allow chaining.
602
- * @public
603
- */
604
- registerRoute( method, path, ...handlers ) {
605
- const verb = TiWebServer.normalizeRegistrableMethod( method );
606
- if ( verb === null ) {
607
- throw exceptions.raise( exceptions.exceptionCode.E_GEN_INVALID_ARGUMENT_TYPE, { method: method } );
608
- }
609
- if ( !this.#webServer ) {
610
- throw exceptions.raise( exceptions.exceptionCode.E_GEN_NOT_INITIALIZED, { detail: "registerRoute() called before the Express app was created; call it from a defineWebApplicationRoutes() override." } );
611
- }
612
- this.#webServer[ verb ]( path, ...handlers );
613
- return this;
614
- }
615
-
616
- /**
617
- * Adds a pattern to the unprotected-routes list — routes that bypass the authentication gate. A string is
618
- * matched exactly against the request path; a RegExp is tested against it. Consulted at request time by
619
- * {@link TiWebServer#isUnprotectedRoute}.
620
- * <br/>
621
- * NOTE: Call this from a {@link TiWebServer#defineUnprotectedRoutes} override AFTER invoking the base method, to
622
- * extend (rather than replace) the defaults.
623
- *
624
- * @method
625
- * @param {string|RegExp} pattern The exact path (string) or path matcher (RegExp) to treat as unprotected.
626
- * @returns {TiWebServer} This instance, to allow chaining.
627
- * @public
628
- */
629
- addUnprotectedRoute( pattern ) {
630
- if ( _.isString( pattern ) || _.isRegExp( pattern ) ) {
631
- this.#unprotectedRoutes.push( pattern );
632
- } else {
633
- logger.log( `Ignored an invalid unprotected-route pattern of type '${ typeof pattern }'; expected a string or RegExp.`, logger.logSeverity.WARNING );
634
- }
635
- return this;
636
- }
637
-
638
- /* Static interface */
639
-
640
- /**
641
- * The Express routing verbs that {@link TiWebServer#registerRoute} will register. Deliberately limited to
642
- * route-scoped methods — `use` (global middleware mounting) is intentionally excluded; add a dedicated seam if
643
- * middleware mounting is ever needed.
644
- *
645
- * @type {Set<string>}
646
- * @private
647
- */
648
- static #REGISTRABLE_METHODS = new Set( [ "get", "post", "put", "patch", "delete", "options", "head", "all" ] );
649
-
650
- /**
651
- * The default `/static` cache policy: revalidate every use, with a long-lived exception for web fonts.
652
- * <br/>
653
- * The default used to be `max-age=1y, immutable`, which was wrong for every consumer that does not hash its asset
654
- * filenames — and none of them do by default, since the framework's own assets ship under stable names
655
- * (`/static/scripts/ti-framework.js`, the theme sheets, …). `immutable` promises that the bytes behind THIS URL
656
- * will never change, and browsers honour it so completely that not even a manual reload revalidates: a deployed
657
- * CSS or JS fix would simply never reach anyone who had already visited, for up to a year, with no way to tell
658
- * them otherwise. Revalidating is the only default that is true for a stable filename; `send` still attaches an
659
- * `ETag`/`Last-Modified`, so the cost is a conditional request answered with a 304, not a re-download.
660
- * <br/>
661
- * A consumer that fingerprints its filenames (`app.a1b2c3.css`) makes the promise true and should opt back in via
662
- * `staticCache: { maxAge: 31536000, immutable: true }`.
663
- * <br/>
664
- * NOTE: These defaults deliberately live here rather than in `web-server.json`, because the constructor merges the
665
- * service config with `_.merge`, which merges arrays BY INDEX — a consumer's `immutablePaths: []` could then never
666
- * clear a default entry. Absent from the config file, an explicitly empty array means exactly that.
667
- *
668
- * @type {Object}
669
- * @private
670
- */
671
- static #STATIC_CACHE_DEFAULTS = Object.freeze( {
672
- maxAge: 0,
673
- immutable: false,
674
- // Fonts are the one genuinely content-addressed-in-practice class under `/static`: a released `.woff2` is an
675
- // artifact, not something that gets edited in place, and its filename already carries the family, weight and
676
- // style. Configurable, because that is a statement about how a given deployment manages its font files.
677
- immutablePaths: Object.freeze( [ "/fonts/" ] )
678
- } );
679
-
680
- /**
681
- * The `max-age` applied to a path matched by `staticCache.immutablePaths`, in seconds (one year the longest
682
- * value any cache treats as meaningful, and the conventional pairing for `immutable`).
683
- *
684
- * @type {number}
685
- * @private
686
- */
687
- static #IMMUTABLE_MAX_AGE = 31536000;
688
-
689
- /**
690
- * Normalizes an `immutablePaths` entry to a rooted, slash-terminated prefix (`fonts` -> `/fonts/`), or null when
691
- * it is not usable. The trailing slash is what keeps `/fonts` from also matching `/fonts-legacy/a.woff2`.
692
- *
693
- * @method
694
- * @static
695
- * @param {string} entry
696
- * @returns {string|null}
697
- * @private
698
- */
699
- static #normalizeImmutablePath( entry ) {
700
- if ( typeof entry !== "string" || entry.trim() === "" ) {
701
- return null;
702
- }
703
- const trimmed = entry.trim();
704
- const rooted = trimmed.startsWith( "/" ) ? trimmed : "/" + trimmed;
705
- return rooted.endsWith( "/" ) ? rooted : rooted + "/";
706
- }
707
-
708
- /**
709
- * Derives the served path of a static file (the part after the `/static` mount, always slash-separated) from the
710
- * directory it is served out of and its absolute location on disk. A file resolving outside the root yields a
711
- * `/../`-prefixed path, which matches no normalized prefix and therefore falls back to the default policy.
712
- *
713
- * @method
714
- * @static
715
- * @param {string} rootPath
716
- * @param {string} filePath
717
- * @returns {string}
718
- * @private
719
- */
720
- static #toServedPath( rootPath, filePath ) {
721
- // Split on the platform separator only: on POSIX a backslash is a legal filename character, not a delimiter.
722
- return "/" + path.relative( String( rootPath || "" ), String( filePath || "" ) ).split( path.sep ).join( "/" );
723
- }
724
-
725
- /**
726
- * Resolves a `staticCache` configuration block into the policy the `/static` mounts apply, filling in
727
- * {@link TiWebServer.#STATIC_CACHE_DEFAULTS} per key and rejecting values that cannot be honored. Pure: problems
728
- * are returned as `warnings` rather than logged, so the caller decides how to surface them and a test can assert
729
- * on them. Static and exposed for unit testing not part of the customization surface.
730
- * <br/>
731
- * `maxAge` is a whole number of SECONDS, mapping 1:1 onto the `Cache-Control` directive — express's `"1y"`-style
732
- * duration strings are NOT accepted, and are reported rather than silently reinterpreted as milliseconds.
733
- * <br/>
734
- * `immutable` is dropped (with a warning) when `maxAge` is 0, because a response that is stale on arrival yet
735
- * promises never to change is a contradiction. Dropping it fails safe: the misconfiguration costs a revalidation,
736
- * not a year of unreachable assets.
737
- *
738
- * @method
739
- * @static
740
- * @param {SettingsStaticCache} [staticCache] The configured block, if any.
741
- * @returns {{maxAge: number, immutable: boolean, immutablePaths: string[], warnings: string[]}}
742
- * @public
743
- */
744
- static resolveStaticCachePolicy( staticCache ) {
745
- const defaults = TiWebServer.#STATIC_CACHE_DEFAULTS;
746
- const config = _.isObjectLike( staticCache ) ? staticCache : {};
747
- const warnings = [];
748
-
749
- let maxAge = defaults.maxAge;
750
- if ( config.maxAge !== undefined ) {
751
- if ( Number.isInteger( config.maxAge ) && config.maxAge >= 0 ) {
752
- maxAge = config.maxAge;
753
- } else {
754
- warnings.push( `Ignored an invalid 'staticCache.maxAge' value of '${ config.maxAge }'; it must be a whole, non-negative number of seconds (a duration string such as '1y' is not accepted). Using ${ defaults.maxAge } instead.` );
755
- }
756
- }
757
-
758
- let immutable = defaults.immutable;
759
- if ( config.immutable !== undefined ) {
760
- if ( typeof config.immutable === "boolean" ) {
761
- immutable = config.immutable;
762
- } else {
763
- warnings.push( `Ignored a non-boolean 'staticCache.immutable' value of '${ config.immutable }'. Using ${ defaults.immutable } instead.` );
764
- }
765
- }
766
- if ( immutable === true && maxAge === 0 ) {
767
- warnings.push( `Ignored 'staticCache.immutable' because 'staticCache.maxAge' is 0 — a response that is stale on arrival cannot also promise never to change. Set a positive 'staticCache.maxAge' (and hash your asset filenames) to serve '/static' as immutable.` );
768
- immutable = false;
769
- }
770
-
771
- let immutablePaths = defaults.immutablePaths.slice();
772
- if ( config.immutablePaths !== undefined ) {
773
- if ( Array.isArray( config.immutablePaths ) ) {
774
- immutablePaths = [];
775
- config.immutablePaths.forEach( ( entry ) => {
776
- const normalized = TiWebServer.#normalizeImmutablePath( entry );
777
- if ( normalized === null ) {
778
- warnings.push( `Ignored an invalid 'staticCache.immutablePaths' entry of type '${ typeof entry }'; expected a non-empty path prefix such as '/fonts/'.` );
779
- } else {
780
- immutablePaths.push( normalized );
781
- }
782
- } );
783
- } else {
784
- warnings.push( `Ignored a non-array 'staticCache.immutablePaths' value of type '${ typeof config.immutablePaths }'. Using the default [ ${ defaults.immutablePaths.join( ", " ) } ] instead.` );
785
- }
786
- }
787
-
788
- return { maxAge: maxAge, immutable: immutable, immutablePaths: immutablePaths, warnings: warnings };
789
- }
790
-
791
- /**
792
- * Builds the `Cache-Control` value for one static file: the long-lived immutable policy when its served path sits
793
- * under a configured `immutablePaths` prefix (matched case-sensitively, so a case mismatch falls back to the safe
794
- * side), otherwise the policy's own `maxAge`/`immutable`. A `maxAge` of 0 is emitted as an explicit
795
- * `must-revalidate` rather than a bare `max-age=0`, matching what the sibling `web-content` package serves.
796
- * Pure and static; exposed for unit testing not part of the customization surface.
797
- *
798
- * @method
799
- * @static
800
- * @param {string} rootPath The directory this `/static` mount serves.
801
- * @param {string} filePath The absolute path of the file being served.
802
- * @param {Object} policy A policy as returned by {@link TiWebServer.resolveStaticCachePolicy}.
803
- * @returns {string}
804
- * @public
805
- */
806
- static staticCacheControlFor( rootPath, filePath, policy ) {
807
- const resolved = _.isObjectLike( policy ) ? policy : {};
808
- const immutablePaths = Array.isArray( resolved.immutablePaths ) ? resolved.immutablePaths : [];
809
- const servedPath = TiWebServer.#toServedPath( rootPath, filePath );
810
-
811
- if ( immutablePaths.some( ( prefix ) => servedPath.startsWith( prefix ) ) === true ) {
812
- return `public, max-age=${ TiWebServer.#IMMUTABLE_MAX_AGE }, immutable`;
813
- }
814
-
815
- const maxAge = ( Number.isInteger( resolved.maxAge ) && resolved.maxAge >= 0 ) ? resolved.maxAge : 0;
816
- if ( maxAge === 0 ) {
817
- return "public, max-age=0, must-revalidate";
818
- }
819
- return ( resolved.immutable === true ) ? `public, max-age=${ maxAge }, immutable` : `public, max-age=${ maxAge }`;
820
- }
821
-
822
- /**
823
- * Normalizes an HTTP method to a lower-case Express routing verb, or returns null if it is not a supported,
824
- * registrable verb. Anything that is not a string is rejected outright rather than coerced otherwise a value
825
- * whose `toString()` happens to yield a verb (`[ "get" ]`, `new String( "get" )`) would register a route and
826
- * bypass the `E_GEN_INVALID_ARGUMENT_TYPE` that {@link TiWebServer#registerRoute} raises for a bad method.
827
- * Pure and static; exposed for unit testing not part of the customization surface.
828
- *
829
- * @method
830
- * @static
831
- * @param {string} method
832
- * @returns {string|null}
833
- * @public
834
- */
835
- static normalizeRegistrableMethod( method ) {
836
- if ( typeof method !== "string" ) {
837
- return null;
838
- }
839
- const verb = method.trim().toLowerCase();
840
- return TiWebServer.#REGISTRABLE_METHODS.has( verb ) ? verb : null;
841
- }
842
-
843
- /**
844
- * Tests a request path against a list of unprotected-route patterns (string exact-match or RegExp test),
845
- * returning true on the first match. A RegExp's `lastIndex` is reset defensively so a stateful 'g'/'y' flag
846
- * cannot cause a match to be skipped. Pure and static; shared by {@link TiWebServer#isUnprotectedRoute} and
847
- * exposed for unit testing not part of the customization surface.
848
- *
849
- * @method
850
- * @static
851
- * @param {Array<string|RegExp>} patterns
852
- * @param {string} pathOnly The request path with any query string already stripped.
853
- * @returns {boolean}
854
- * @public
855
- */
856
- static isRouteInList( patterns, pathOnly ) {
857
- for ( let idx = 0; idx < patterns.length; idx++ ) {
858
- const pattern = patterns[ idx ];
859
- let matched;
860
- if ( _.isRegExp( pattern ) ) {
861
- // Avoid stateful RegExp behavior when 'g' or 'y' flags are present:
862
- pattern.lastIndex = 0;
863
- matched = pattern.test( pathOnly );
864
- } else {
865
- matched = ( pattern === pathOnly );
866
- }
867
- if ( matched === true ) {
868
- return true;
869
- }
870
- }
871
- return false;
872
- }
873
-
874
- /* Private interface */
875
-
876
- /**
877
- * Used to start listening for requests on the specified port and host and on the specified server.
878
- *
879
- * @method
880
- * @param {NodeServer} server The server instance to listen on.
881
- * @param {number} port The port to listen on.
882
- * @param {string} host The host to listen on.
883
- * @returns {Promise<NodeServer>}
884
- * @private
885
- */
886
- #beginListening( server, port, host ) {
887
- return new Promise( ( resolve, reject ) => {
888
- server.once( "error", ( error ) => {
889
- reject( exceptions.raise( error ) );
890
- } );
891
- server.once( "listening", () => {
892
- resolve( server );
893
- } );
894
- server.listen( port, host );
895
- } );
896
- }
897
-
898
- /**
899
- * Used to stop listening for requests on the specified server.
900
- *
901
- * @method
902
- * @param {NodeServer} server The server instance to stop listening on.
903
- * @returns {Promise}
904
- * @private
905
- */
906
- #endListening( server ) {
907
- return new Promise( ( resolve, reject ) => {
908
- if ( !server ) {
909
- resolve();
910
- } else {
911
- // Close all connections after a short delay to allow all requests to complete:
912
- setTimeout( () => {
913
- if ( typeof server.closeIdleConnections === "function" ) {
914
- server.closeIdleConnections();
915
- }
916
- if ( typeof server.closeAllConnections === "function" ) {
917
- server.closeAllConnections();
918
- }
919
- }, 1000 );
920
-
921
- server.close( ( error ) => {
922
- if ( error ) {
923
- reject( exceptions.raise( error ) );
924
- } else {
925
- resolve();
926
- }
927
- } );
928
- }
929
- } );
930
- }
931
-
932
- }
933
-
934
- module.exports = TiWebServer;
935
- // Exported for unit testing of the ReDoS-hardened matchers; not part of the customization surface.
936
- module.exports.RE_STATIC_UNPROTECTED = RE_STATIC_UNPROTECTED;
937
- module.exports.RE_WELL_KNOWN_UNPROTECTED = RE_WELL_KNOWN_UNPROTECTED;
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 ServiceConsumer = require( "@ti-engine/core/service-consumer" );
10
+ const exceptions = require( "@ti-engine/core/exceptions" );
11
+ const logger = require( "@ti-engine/core/logger" );
12
+ const { randomBytes } = require( "node:crypto" );
13
+ const path = require( "node:path" );
14
+ const fs = require( "node:fs" );
15
+ const _ = require( "lodash" );
16
+ const express = require( "express" );
17
+ const helmet = require( "helmet" );
18
+ const session = require( "express-session" );
19
+ const cookieParser = require( "cookie-parser" );
20
+ const webHandlers = require( "#web-handlers" );
21
+ const SessionStore = require( "#session-store" );
22
+ const AuthManager = require( "#auth-manager" );
23
+ const authMethod = require( "#auth-manager" ).authMethod;
24
+ const authorization = require( "#authorization" );
25
+ const adminConfigHandlers = require( "#admin-config-handlers" );
26
+ const configService = require( "#config-service" );
27
+ const applyWebConfigEnvOverrides = require( "#web-config-env" );
28
+
29
+ /** @typedef {import("node:http").Server} NodeServer */
30
+
31
+ /**
32
+ * @typedef {ServiceConfiguration} TiWebServiceConfiguration
33
+ * @property {ApiConfig} api
34
+ * @property {TiWebApplicationConfig} application
35
+ * @property {SettingsAuth} auth
36
+ * @property {SettingsCookies} cookies
37
+ * @property {string} host
38
+ * @property {TiLocalizationLanguage} language
39
+ * @property {number} port
40
+ * @property {string} publicPath
41
+ * @property {number} requestTimeout
42
+ * @property {SettingsStaticCache} staticCache
43
+ * @property {string} tlsCertPath
44
+ * @property {string} tlsKeyPath
45
+ * @property {boolean} useTLS
46
+ */
47
+
48
+ /**
49
+ * @typedef {Object} TiWebApplicationConfig
50
+ * @property {string} classPath
51
+ */
52
+
53
+ /**
54
+ * @typedef {Object} ApiConfig
55
+ * @property {boolean} endpointEnabled
56
+ * @property {ApiInventory} inventory
57
+ * @property {number} requestTimeout
58
+ */
59
+
60
+ /**
61
+ * @typedef {Object} SettingsAuth
62
+ * @property {string[]} enabledMethods
63
+ * @property {Object} local
64
+ * @property {Object} oauth2
65
+ * @property {SettingsOAuth2Client} [oauth2.azure]
66
+ * @property {SettingsOAuth2Client} [oauth2.google]
67
+ */
68
+
69
+ /**
70
+ * @typedef {Object} SettingsOAuth2Client
71
+ * @property {string} [clientID]
72
+ * @property {string} [clientSecret]
73
+ * @property {string} [callbackUrl]
74
+ * @property {string} [discoveryUrl]
75
+ * @property {boolean} [isPublic]
76
+ * @property {TiTokenEndpointAuthMethod} [tokenEndpointAuthMethod]
77
+ */
78
+
79
+ /**
80
+ * @typedef {Object} SettingsStaticCache
81
+ * @property {number} maxAge The `max-age` for `/static` responses, in SECONDS (not a duration string). `0` means every use is revalidated.
82
+ * @property {boolean} immutable Whether to add `immutable`. Only correct when the `/static` filenames are content-addressed.
83
+ * @property {string[]} immutablePaths Path prefixes under `/static` that are served long-lived and `immutable` regardless of the two settings above.
84
+ */
85
+
86
+ /**
87
+ * @typedef {Object} SettingsCookies
88
+ * @property {string} secret
89
+ * @property {string} path
90
+ * @property {boolean} httpOnly
91
+ * @property {"lax"|"strict"|"none"} sameSite
92
+ * @property {number} maxAge
93
+ */
94
+
95
+ /**
96
+ * @typedef {Record<string, Record<string, ServiceAddress>>} ApiInventory
97
+ */
98
+
99
+ const webServerConfig = require( "#web-server-config" );
100
+
101
+ /** @import { TiAuthMethod, TiTokenEndpointAuthMethod } from "#auth-manager" */
102
+ /** @import { TiSession } from "#definitions" */
103
+ /** @import User from "#user" */
104
+ /** @import TiWebAppManager from "#web-app-manager" */
105
+ /** @import { ServiceAddress, ServiceConfiguration } from "@ti-engine/core/definitions" */
106
+
107
+ /**
108
+ * Default unprotected static-asset route matchers. The path segments are matched with `(?:[^/]+\/)*` rather than
109
+ * `(?:.+\/)*`: the inner `[^/]+` cannot also consume the "/" delimiter, so the pattern is unambiguous and matches
110
+ * in linear time. The previous `.+` form was ambiguous and backtracked exponentially on hostile request paths such
111
+ * as `/static/a/a/…/a/x` (no trailing extension) — and these matchers run against the raw request path in
112
+ * {@link TiWebServer#isUnprotectedRoute} BEFORE authentication, so that was a pre-auth denial-of-service vector
113
+ * (CodeQL js/redos). The matched language for realistic asset paths is unchanged.
114
+ *
115
+ * @type {RegExp}
116
+ */
117
+ const RE_STATIC_UNPROTECTED = /^\/static\/(?:[^/]+\/)*[^/]+\.[^/]+$/i;
118
+
119
+ /**
120
+ * Default unprotected `/.well-known/` route matcher. See {@link RE_STATIC_UNPROTECTED} for the ReDoS rationale.
121
+ *
122
+ * @type {RegExp}
123
+ */
124
+ const RE_WELL_KNOWN_UNPROTECTED = /^\/\.well-known\/(?:[^/]+\/)*[^/]+\.[^/]+$/i;
125
+
126
+ /**
127
+ * A web server microservice based on the ti-engine.
128
+ * <br/>
129
+ * Note: The web server is fully functional and already comes with all the necessary fundamentals and security features. However, it is designed to be extended
130
+ * with custom logic and functionality to fit your specific needs. Here is a list of methods that you can override to customize the web server behavior:
131
+ * - {@link TiWebServer#defineWebApplicationRoutes} Override this to define custom web application routes. Remember to call the base method if you want to preserve the default behavior as well (recommended).
132
+ * - {@link TiWebServer#defineUnprotectedRoutes} Override this to define unprotected routes. Remember to call the base method if you want to preserve the default behavior as well (recommended).
133
+ * - {@link TiWebServer#verifySession} Override this to implement custom session verification logic.
134
+ *
135
+ * @class TiWebServer
136
+ * @extends ServiceConsumer
137
+ * @public
138
+ */
139
+ class TiWebServer extends ServiceConsumer {
140
+
141
+ #webServer;
142
+ #netServer;
143
+ #serverUrl = "";
144
+ #isShuttingDown = false;
145
+ #staticContentPaths = [];
146
+ #allowedHosts = [];
147
+ #unprotectedRoutes = [];
148
+ #webAppManager;
149
+ #authManager;
150
+
151
+ /**
152
+ * @constructor
153
+ * @param {string} serviceDomainName The service domain name for this service instance.
154
+ * @param {TiWebServiceConfiguration} serviceConfig The JSON configuration for this service. Note that the configuration provided will be merged with the default web server configuration, and it will override any conflicting properties.
155
+ * @throws {TiException.E_GEN_JS_INTERNAL_ERROR} If the web application manager cannot be loaded.
156
+ */
157
+ constructor( serviceDomainName, serviceConfig ) {
158
+ super( serviceDomainName, applyWebConfigEnvOverrides( _.merge( {}, webServerConfig, ( _.isObjectLike( serviceConfig ) ) ? serviceConfig : {} ) ) );
159
+
160
+ // Include the current host in the list of allowed hosts:
161
+ this.#allowedHosts.push( this.serviceConfig.host );
162
+
163
+ // Add the default and custom public paths to the list of static content:
164
+ this.#staticContentPaths.push( path.join( __dirname, "static" ) );
165
+ let customStaticContentPath = path.normalize( path.isAbsolute( this.serviceConfig.publicPath ) ? this.serviceConfig.publicPath : path.join( process.cwd(), this.serviceConfig.publicPath ) );
166
+ if ( fs.existsSync( customStaticContentPath ) === false ) {
167
+ logger.log( `Public path '${ customStaticContentPath }' does not exist. Static routes will resolve with 404 until path is created.`, logger.logSeverity.WARNING );
168
+ } else {
169
+ this.#staticContentPaths.push( customStaticContentPath );
170
+ }
171
+
172
+ this.#authManager = new AuthManager( this.serviceConfig.auth );
173
+
174
+ // If there is a web application configuration, create the web application manager:
175
+ if ( this.serviceConfig.application ) {
176
+ try {
177
+ const webApplicationConstructor = require( path.join( process.cwd(), this.serviceConfig.application.classPath ) );
178
+ this.#webAppManager = new webApplicationConstructor();
179
+ } catch ( error ) {
180
+ logger.log( `Failed to load web application manager from '${ this.serviceConfig.application.classPath }'`, logger.logSeverity.ERROR, error );
181
+ throw exceptions.raise( error );
182
+ }
183
+ }
184
+ }
185
+
186
+ /* Public interface */
187
+
188
+ /**
189
+ * Property returning the service configuration JSON.
190
+ *
191
+ * @property
192
+ * @returns {TiWebServiceConfiguration}
193
+ * @override
194
+ * @public
195
+ */
196
+ get serviceConfig() {
197
+ return super.serviceConfig;
198
+ }
199
+
200
+ /**
201
+ * Property returning if the web server is currently shutting down.
202
+ *
203
+ * @property
204
+ * @returns {boolean}
205
+ * @public
206
+ */
207
+ get isShuttingDown() {
208
+ return this.#isShuttingDown;
209
+ }
210
+
211
+ /**
212
+ * Property returning the list of static content directories.
213
+ *
214
+ * @property
215
+ * @returns {string[]}
216
+ * @public
217
+ */
218
+ get staticContentPaths() {
219
+ return this.#staticContentPaths;
220
+ }
221
+
222
+ /**
223
+ * Property returning the server URL.
224
+ *
225
+ * @property
226
+ * @returns {string}
227
+ * @public
228
+ */
229
+ get serverUrl() {
230
+ return this.#serverUrl;
231
+ }
232
+
233
+ /**
234
+ * Property returning the {@link TiWebAppManager} instance.
235
+ *
236
+ * @property
237
+ * @returns {TiWebAppManager}
238
+ * @public
239
+ */
240
+ get webAppManager() {
241
+ return this.#webAppManager;
242
+ }
243
+
244
+ /**
245
+ * Starts the web server.
246
+ *
247
+ * @method
248
+ * @returns {Promise}
249
+ * @override
250
+ * @public
251
+ */
252
+ onStart() {
253
+ return new Promise( ( resolve, reject ) => {
254
+ super.onStart().then( () => {
255
+ // Create and configure the web server:
256
+ this.#webServer = express();
257
+ this.#webServer.set( "trust proxy", true );
258
+
259
+ // Create and configure the net server for HTTPS if enabled in the service config:
260
+ let netServerOptions = {};
261
+ const timeoutCandidates = [
262
+ this.serviceConfig.api.requestTimeout,
263
+ this.serviceConfig.requestTimeout
264
+ ].filter( ( value ) => Number.isFinite( value ) );
265
+ const resolvedRequestTimeout = timeoutCandidates.length ? Math.max( ...timeoutCandidates ) : undefined;
266
+ if ( this.serviceConfig.useTLS === true ) {
267
+ if ( !this.serviceConfig.tlsKeyPath || !this.serviceConfig.tlsCertPath ) {
268
+ // Abort initialization if there is something wrong with the TLS key or cert paths:
269
+ return reject( exceptions.raise( exceptions.exceptionCode.E_GEN_INVALID_ARGUMENT_TYPE, {
270
+ tlsKeyPath: this.serviceConfig.tlsKeyPath,
271
+ tlsCertPath: this.serviceConfig.tlsCertPath
272
+ }, exceptions.httpCode.C_500 ) );
273
+ }
274
+ netServerOptions.key = fs.readFileSync( path.join( process.cwd(), this.serviceConfig.tlsKeyPath ) );
275
+ netServerOptions.cert = fs.readFileSync( path.join( process.cwd(), this.serviceConfig.tlsCertPath ) );
276
+
277
+ this.#webServer.use( webHandlers.httpRedirectHandler( this ) );
278
+ this.#netServer = require( "node:https" ).createServer( netServerOptions, this.#webServer );
279
+ } else {
280
+ this.#netServer = require( "node:http" ).createServer( netServerOptions, this.#webServer );
281
+ }
282
+ if ( Number.isFinite( resolvedRequestTimeout ) ) {
283
+ this.#netServer.requestTimeout = resolvedRequestTimeout;
284
+ this.#netServer.headersTimeout = resolvedRequestTimeout + 100;
285
+ if ( typeof this.#netServer.keepAliveTimeout === "number" ) {
286
+ this.#netServer.keepAliveTimeout = resolvedRequestTimeout + 1000;
287
+ }
288
+ }
289
+
290
+ // Set up security and session middlewares first:
291
+ this.#webServer.use( webHandlers.nonceGenerationHandler() );
292
+ // Helmet's built-in Content-Security-Policy is intentionally disabled here because a per-request,
293
+ // nonce-based CSP is enforced on the very next line by webHandlers.cspHeaderHandler() (see
294
+ // components/web-handlers.js) Helmet's static config cannot express per-response nonces. Every other
295
+ // Helmet header (HSTS, X-Content-Type-Options, X-Frame-Options, …) still applies. This is a deliberate
296
+ // architecture, not missing CSP; do not enable Helmet's static CSP here, as that would drop the nonce.
297
+ // codeql[js/insecure-helmet-configuration]
298
+ this.#webServer.use( helmet( { contentSecurityPolicy: false } ) );
299
+ this.#webServer.use( webHandlers.cspHeaderHandler() );
300
+ this.#webServer.use( express.json( { limit: "1mb" } ) );
301
+ this.#webServer.use( express.urlencoded( { extended: false, limit: "100kb" } ) );
302
+ this.#webServer.use( cookieParser() );
303
+ this.#webServer.use( session( {
304
+ secret: this.serviceConfig.cookies.secret || randomBytes( 32 ).toString( "base64" ),
305
+ resave: false,
306
+ saveUninitialized: false,
307
+ cookie: {
308
+ path: this.serviceConfig.cookies.path,
309
+ httpOnly: this.serviceConfig.cookies.httpOnly,
310
+ secure: "auto",
311
+ sameSite: this.serviceConfig.cookies.sameSite,
312
+ maxAge: this.serviceConfig.cookies.maxAge
313
+ },
314
+ unset: "destroy",
315
+ store: new SessionStore()
316
+ } ) );
317
+ this.#webServer.use( webHandlers.csrfInitHandler( this ) );
318
+ this.#webServer.use( webHandlers.originRefererValidationHandler( this ) );
319
+ this.#webServer.use( webHandlers.csrfProtectionHandler() );
320
+
321
+ // Set up the web server routes:
322
+ this.#webServer.use( webHandlers.onShutDownHandler( this ) );
323
+ this.#webServer.use( webHandlers.resourceProtectionHandler( this ) );
324
+ this.#webServer.use( "/.well-known", express.static( path.join( this.#staticContentPaths[ 0 ], ".well-known" ), { dotfiles: "allow" } ) );
325
+
326
+ // Static content routes are registered in reverse order to ensure that custom assets can override the default ones and be served first:
327
+ const staticCachePolicy = TiWebServer.resolveStaticCachePolicy( this.serviceConfig.staticCache );
328
+ staticCachePolicy.warnings.forEach( ( warning ) => logger.log( warning, logger.logSeverity.WARNING ) );
329
+ _.forEachRight( this.#staticContentPaths, ( staticContentPath ) => {
330
+ // `Cache-Control` is written per file rather than through express.static's `maxAge`/`immutable`
331
+ // options, because the policy is not uniform across the tree (see resolveStaticCachePolicy). A
332
+ // header set here wins: `send` emits its "headers" event BEFORE its own `Cache-Control` block,
333
+ // which then skips a header that is already present. `ETag`/`Last-Modified` are still added by
334
+ // `send`, so the revalidating default costs a conditional request answered with a 304, not a
335
+ // re-download.
336
+ this.#webServer.use( "/static", express.static( staticContentPath, {
337
+ setHeaders: ( response, filePath ) => {
338
+ response.setHeader( "Cache-Control", TiWebServer.staticCacheControlFor( staticContentPath, filePath, staticCachePolicy ) );
339
+ }
340
+ } ) );
341
+ } );
342
+
343
+ // Set up the web application routes:
344
+ this.defineWebApplicationRoutes();
345
+
346
+ // API service proxy route (protected by auth middleware):
347
+ if ( this.serviceConfig.api.endpointEnabled === true ) {
348
+ this.#webServer.post( "/service/:version/:name", webHandlers.serviceCallHandler( this ) );
349
+ }
350
+
351
+ // Set up error handling middleware:
352
+ this.#webServer.all( "*splat", webHandlers.invalidRouteHandler() );
353
+ this.#webServer.use( webHandlers.defaultErrorHandler() );
354
+
355
+ // Set up the unprotected routes:
356
+ this.defineUnprotectedRoutes();
357
+
358
+ return this.#authManager.initialize();
359
+ } ).then( () => {
360
+ // Hand the web application manager the effective enabled auth methods (after any unconfigured OpenID
361
+ // providers were dropped) so the login page only renders providers a user can actually complete.
362
+ if ( this.#webAppManager && typeof this.#webAppManager.setEnabledAuthMethods === "function" ) {
363
+ this.#webAppManager.setEnabledAuthMethods( this.#authManager.getEnabledMethods() );
364
+ }
365
+ return this.#beginListening( this.#netServer, this.serviceConfig.port, this.serviceConfig.host );
366
+ } ).then( ( server ) => {
367
+ if ( server.listening === true ) {
368
+ this.#serverUrl = `http${ this.serviceConfig.useTLS === true ? "s" : "" }://${ server.address().address }:${ server.address().port }`;
369
+ logger.log( `Web server started at address '${ this.#serverUrl }' within instance '${ ServiceConsumer.instanceID }'.`, logger.logSeverity.NOTICE );
370
+ } else {
371
+ logger.log( `Web server is not listening for requests after startup within instance '${ ServiceConsumer.instanceID }'.`, logger.logSeverity.WARNING );
372
+ }
373
+ resolve();
374
+ } ).catch( ( error ) => {
375
+ logger.log( `Error while trying to start web server within instance '${ ServiceConsumer.instanceID }'!`, logger.logSeverity.ERROR, error );
376
+ reject( exceptions.raise( error ) );
377
+ } );
378
+ } );
379
+ }
380
+
381
+ /**
382
+ * Shuts down the web server.
383
+ *
384
+ * @method
385
+ * @returns {Promise}
386
+ * @override
387
+ * @public
388
+ */
389
+ onStop() {
390
+ return new Promise( ( resolve, reject ) => {
391
+ this.#isShuttingDown = true;
392
+
393
+ super.onStop().then( () => {
394
+ return this.#endListening( this.#netServer );
395
+ } ).then( () => {
396
+ logger.log( `Web server stopped successfully.`, logger.logSeverity.NOTICE );
397
+ resolve();
398
+ } ).catch( ( error ) => {
399
+ reject( exceptions.raise( error ) );
400
+ } );
401
+ } );
402
+ }
403
+
404
+ /**
405
+ * Used to report health status of the service instance for external monitoring.
406
+ * This is a scheduled job that will be executed at SERVICE_HEALTH_CHECK_INTERVAL time.
407
+ *
408
+ * @method
409
+ * @override
410
+ * @public
411
+ */
412
+ reportHealthy() {
413
+ super.reportHealthy();
414
+ }
415
+
416
+ /**
417
+ * Used to verify the session of a request.
418
+ *
419
+ * @method
420
+ * @param {TiSession} session
421
+ * @returns {boolean}
422
+ * @public
423
+ */
424
+ verifySession( session ) {
425
+ // TODO: Implement this!
426
+ return Boolean( session && session.user );
427
+ }
428
+
429
+ /**
430
+ * Hook for the application to augment the freshly-authenticated session (e.g. derive domain roles from an
431
+ * identity store or the org chart). Runs synchronously, once per login, before the framework's additive `admin`
432
+ * role is applied. The default is a no-op. Any test-user role injection is an override of whatever the app derives.
433
+ *
434
+ * @method
435
+ * @virtual
436
+ * @param {TiSession} session
437
+ * @param {Object} [request] Optional Express request object that can be used to read body/cookies/query data.
438
+ * @returns {TiSession}
439
+ * @public
440
+ */
441
+ augmentSession( session, request ) {
442
+ return session;
443
+ }
444
+
445
+ /**
446
+ * Used to authenticate a user via the specified auth method.
447
+ *
448
+ * @method
449
+ * @param {TiAuthMethod} authMethod
450
+ * @param {Object} [authDetails={}]
451
+ * @returns {Promise}
452
+ * @public
453
+ */
454
+ authenticate( authMethod, authDetails = {} ) {
455
+ return this.#authManager.authenticate( authMethod, authDetails );
456
+ }
457
+
458
+ /**
459
+ * Used to set up user authorization according to the specified auth method.
460
+ *
461
+ * @method
462
+ * @param {TiAuthMethod} authMethod
463
+ * @param {URL} currentUrl
464
+ * @param {Object} oidc
465
+ * @returns {Promise<User>}
466
+ * @public
467
+ */
468
+ authorize( authMethod, currentUrl, oidc ) {
469
+ return this.#authManager.authorize( authMethod, currentUrl, oidc );
470
+ }
471
+
472
+ /**
473
+ * Used to get a service mapping if such exists.
474
+ *
475
+ * @method
476
+ * @param {string} serviceVersion
477
+ * @param {string} serviceName
478
+ * @returns {ServiceAddress}
479
+ * @public
480
+ */
481
+ getServiceAddress( serviceVersion, serviceName ) {
482
+ let serviceAddress = undefined;
483
+ if ( this.serviceConfig.api && this.serviceConfig.api.inventory ) {
484
+ serviceAddress = ( this.serviceConfig.api.inventory[ serviceVersion ] ) ? this.serviceConfig.api.inventory[ serviceVersion ][ serviceName ] : undefined;
485
+ }
486
+ return serviceAddress;
487
+ }
488
+
489
+ /**
490
+ * Used to check if the specified hostname is allowed to access the web server.
491
+ *
492
+ * @method
493
+ * @param {string} hostname
494
+ * @returns {boolean}
495
+ * @public
496
+ */
497
+ isAllowedHost( hostname ) {
498
+ return this.#allowedHosts.includes( hostname );
499
+ }
500
+
501
+ /**
502
+ * Used to check if the specified route is unprotected (i.e., does not require authentication). The default unprotected routes are:
503
+ * - /
504
+ * - /static/...
505
+ * - /.well-known/...
506
+ * - /not-found
507
+ * - /app
508
+ * - /app/enter
509
+ * - /app/config
510
+ * - /logout
511
+ * - /login/:method
512
+ * <br/>
513
+ * NOTE: You can define custom unprotected routes by overriding the {@link TiWebServer#defineUnprotectedRoutes} method.
514
+ *
515
+ * @method
516
+ * @param {string} route
517
+ * @returns {boolean}
518
+ * @public
519
+ */
520
+ isUnprotectedRoute( route ) {
521
+ const pathOnly = String( route || "" ).split( "?" )[ 0 ];
522
+ return TiWebServer.isRouteInList( this.#unprotectedRoutes, pathOnly );
523
+ }
524
+
525
+ /**
526
+ * Used to define the web application routes.
527
+ * <br/>
528
+ * NOTE: Override this to define custom web application routes. Remember to call the base method if you want to preserve the default behavior as well.
529
+ *
530
+ * @method
531
+ * @virtual
532
+ * @public
533
+ */
534
+ defineWebApplicationRoutes() {
535
+ this.#webServer.get( "/", webHandlers.webAppHandler( this ) );
536
+ this.#webServer.get( "/not-found", webHandlers.webAppHandler( this ) );
537
+ this.#webServer.get( "/app", webHandlers.webAppHandler( this ) );
538
+ this.#webServer.get( "/app/:view", webHandlers.webAppHandler( this ) );
539
+ this.#webServer.post( "/app/:service", webHandlers.webAppHandler( this ) );
540
+ this.#webServer.get( "/login/:method", webHandlers.authenticationHandler( this ) );
541
+ this.#webServer.post( "/login/:method", webHandlers.authenticationHandler( this ) );
542
+ this.#webServer.post( "/logout", webHandlers.logoutHandler() );
543
+ this.#webServer.get( "/health", webHandlers.healthHandler() );
544
+ this.#webServer.get( "/me", webHandlers.userInformationHandler() );
545
+ // NOTE: A callback is registered by its path, never by the configured value verbatim — that value is commonly
546
+ // the absolute URL registered with the identity provider, which Express cannot parse as a route pattern.
547
+ [ authMethod.OPENID_GOOGLE, authMethod.OPENID_AZURE ].forEach( ( method ) => {
548
+ if ( this.#authManager.isAuthEnabled( method ) === true ) {
549
+ const callbackPath = this.#authManager.getOAuth2CallbackPath( method );
550
+ if ( callbackPath ) {
551
+ this.#webServer.get( callbackPath, webHandlers.authorizedOAuth2CallbackHandler( this, method ) );
552
+ } else {
553
+ logger.log( `Authentication method '${ method }' is enabled but its callback URL yields no usable route path; its callback endpoint was not registered and sign-in through it will fail.`, logger.logSeverity.WARNING );
554
+ }
555
+ }
556
+ } );
557
+
558
+ // Admin configuration-management API. Gated by the admin role; these paths are not in the unprotected-routes
559
+ // list, so they also inherit the server's global authentication + CSRF middleware.
560
+ const requireAdmin = authorization.requireAdmin;
561
+ const service = configService.instance;
562
+ this.#webServer.get( "/admin/config/editors", requireAdmin, adminConfigHandlers.listEditors( service ) );
563
+ this.#webServer.get( "/admin/config/editors/:editorKey", requireAdmin, adminConfigHandlers.composeView( service ) );
564
+ this.#webServer.post( "/admin/config/editors/:editorKey", requireAdmin, adminConfigHandlers.saveEditorEdit( service ) );
565
+ this.#webServer.get( "/admin/config/documents/:configKey", requireAdmin, adminConfigHandlers.getCurrent( service ) );
566
+ this.#webServer.get( "/admin/config/documents/:configKey/history", requireAdmin, adminConfigHandlers.getHistory( service ) );
567
+ this.#webServer.get( "/admin/config/changes", requireAdmin, adminConfigHandlers.listChanges( service ) );
568
+ this.#webServer.get( "/admin/config/changes/:changeSetID", requireAdmin, adminConfigHandlers.getChange( service ) );
569
+ this.#webServer.post( "/admin/config/changes/:changeSetID/restore", requireAdmin, adminConfigHandlers.restoreChangeSet( service ) );
570
+ this.#webServer.get( "/admin/config/export", requireAdmin, adminConfigHandlers.exportBundle( service ) );
571
+ }
572
+
573
+ /**
574
+ * Used to define the unprotected routes (i.e., routes that do not require authentication).
575
+ * <br/>
576
+ * NOTE: Override this to define custom unprotected routes. Remember to call the base method if you want to preserve the default behavior as well.
577
+ *
578
+ * @method
579
+ * @virtual
580
+ * @public
581
+ */
582
+ defineUnprotectedRoutes() {
583
+ this.#unprotectedRoutes.push( "/" );
584
+ this.#unprotectedRoutes.push( "/not-found" );
585
+ this.#unprotectedRoutes.push( "/app" );
586
+ this.#unprotectedRoutes.push( "/app/enter" );
587
+ this.#unprotectedRoutes.push( "/app/config" );
588
+ this.#unprotectedRoutes.push( /^\/login\/[^/]+$/i );
589
+ this.#unprotectedRoutes.push( "/logout" );
590
+ this.#unprotectedRoutes.push( "/health" );
591
+ this.#unprotectedRoutes.push( RE_STATIC_UNPROTECTED );
592
+ this.#unprotectedRoutes.push( RE_WELL_KNOWN_UNPROTECTED );
593
+ }
594
+
595
+ /**
596
+ * Registers a custom application route on the underlying Express app.
597
+ * <br/>
598
+ * NOTE: Call this from a {@link TiWebServer#defineWebApplicationRoutes} override AFTER invoking the base method,
599
+ * so the framework's own routes keep priority and any catch-all route you add resolves last (it will still be
600
+ * registered before the framework's own `*splat` 404 handler). It is only valid once the Express app exists —
601
+ * i.e., from within {@link TiWebServer#defineWebApplicationRoutes}, which {@link TiWebServer#onStart} invokes.
602
+ *
603
+ * @method
604
+ * @param {string} method One of the supported routing verbs: get, post, put, patch, delete, options, head, all.
605
+ * @param {string|RegExp} path The route path or pattern.
606
+ * @param {...Function} handlers One or more Express route handlers/middleware.
607
+ * @returns {TiWebServer} This instance, to allow chaining.
608
+ * @public
609
+ */
610
+ registerRoute( method, path, ...handlers ) {
611
+ const verb = TiWebServer.normalizeRegistrableMethod( method );
612
+ if ( verb === null ) {
613
+ throw exceptions.raise( exceptions.exceptionCode.E_GEN_INVALID_ARGUMENT_TYPE, { method: method } );
614
+ }
615
+ if ( !this.#webServer ) {
616
+ throw exceptions.raise( exceptions.exceptionCode.E_GEN_NOT_INITIALIZED, { detail: "registerRoute() called before the Express app was created; call it from a defineWebApplicationRoutes() override." } );
617
+ }
618
+ this.#webServer[ verb ]( path, ...handlers );
619
+ return this;
620
+ }
621
+
622
+ /**
623
+ * Adds a pattern to the unprotected-routes list — routes that bypass the authentication gate. A string is
624
+ * matched exactly against the request path; a RegExp is tested against it. Consulted at request time by
625
+ * {@link TiWebServer#isUnprotectedRoute}.
626
+ * <br/>
627
+ * NOTE: Call this from a {@link TiWebServer#defineUnprotectedRoutes} override AFTER invoking the base method, to
628
+ * extend (rather than replace) the defaults.
629
+ *
630
+ * @method
631
+ * @param {string|RegExp} pattern The exact path (string) or path matcher (RegExp) to treat as unprotected.
632
+ * @returns {TiWebServer} This instance, to allow chaining.
633
+ * @public
634
+ */
635
+ addUnprotectedRoute( pattern ) {
636
+ if ( _.isString( pattern ) || _.isRegExp( pattern ) ) {
637
+ this.#unprotectedRoutes.push( pattern );
638
+ } else {
639
+ logger.log( `Ignored an invalid unprotected-route pattern of type '${ typeof pattern }'; expected a string or RegExp.`, logger.logSeverity.WARNING );
640
+ }
641
+ return this;
642
+ }
643
+
644
+ /* Static interface */
645
+
646
+ /**
647
+ * The Express routing verbs that {@link TiWebServer#registerRoute} will register. Deliberately limited to
648
+ * route-scoped methods `use` (global middleware mounting) is intentionally excluded; add a dedicated seam if
649
+ * middleware mounting is ever needed.
650
+ *
651
+ * @type {Set<string>}
652
+ */
653
+ static #REGISTRABLE_METHODS = new Set( [ "get", "post", "put", "patch", "delete", "options", "head", "all" ] );
654
+
655
+ /**
656
+ * The default `/static` cache policy: revalidate every use, with a long-lived exception for web fonts.
657
+ * <br/>
658
+ * The default used to be `max-age=1y, immutable`, which was wrong for every consumer that does not hash its asset
659
+ * filenames and none of them do by default, since the framework's own assets ship under stable names
660
+ * (`/static/scripts/ti-framework.js`, the theme sheets, …). `immutable` promises that the bytes behind THIS URL
661
+ * will never change, and browsers honour it so completely that not even a manual reload revalidates: a deployed
662
+ * CSS or JS fix would simply never reach anyone who had already visited, for up to a year, with no way to tell
663
+ * them otherwise. Revalidating is the only default that is true for a stable filename; `send` still attaches an
664
+ * `ETag`/`Last-Modified`, so the cost is a conditional request answered with a 304, not a re-download.
665
+ * <br/>
666
+ * A consumer that fingerprints its filenames (`app.a1b2c3.css`) makes the promise true and should opt back in via
667
+ * `staticCache: { maxAge: 31536000, immutable: true }`.
668
+ * <br/>
669
+ * NOTE: These defaults deliberately live here rather than in `web-server.json`, because the constructor merges the
670
+ * service config with `_.merge`, which merges arrays BY INDEX — a consumer's `immutablePaths: []` could then never
671
+ * clear a default entry. Absent from the config file, an explicitly empty array means exactly that.
672
+ *
673
+ * @type {Object}
674
+ */
675
+ static #STATIC_CACHE_DEFAULTS = Object.freeze( {
676
+ maxAge: 0,
677
+ immutable: false,
678
+ // Fonts are the one genuinely content-addressed-in-practice class under `/static`: a released `.woff2` is an
679
+ // artifact, not something that gets edited in place, and its filename already carries the family, weight and
680
+ // style. Configurable, because that is a statement about how a given deployment manages its font files.
681
+ immutablePaths: Object.freeze( [ "/fonts/" ] )
682
+ } );
683
+
684
+ /**
685
+ * The `max-age` applied to a path matched by `staticCache.immutablePaths`, in seconds (one year — the longest
686
+ * value any cache treats as meaningful, and the conventional pairing for `immutable`).
687
+ *
688
+ * @type {number}
689
+ */
690
+ static #IMMUTABLE_MAX_AGE = 31536000;
691
+
692
+ /**
693
+ * Normalizes an `immutablePaths` entry to a rooted, slash-terminated prefix (`fonts` -> `/fonts/`), or null when
694
+ * it is not usable. The trailing slash is what keeps `/fonts` from also matching `/fonts-legacy/a.woff2`.
695
+ *
696
+ * @method
697
+ * @static
698
+ * @param {string} entry
699
+ * @returns {string|null}
700
+ */
701
+ static #normalizeImmutablePath( entry ) {
702
+ if ( typeof entry !== "string" || entry.trim() === "" ) {
703
+ return null;
704
+ }
705
+ const trimmed = entry.trim();
706
+ const rooted = trimmed.startsWith( "/" ) ? trimmed : "/" + trimmed;
707
+ return rooted.endsWith( "/" ) ? rooted : rooted + "/";
708
+ }
709
+
710
+ /**
711
+ * Derives the served path of a static file (the part after the `/static` mount, always slash-separated) from the
712
+ * directory it is served out of and its absolute location on disk. A file resolving outside the root yields a
713
+ * `/../`-prefixed path, which matches no normalized prefix and therefore falls back to the default policy.
714
+ *
715
+ * @method
716
+ * @static
717
+ * @param {string} rootPath
718
+ * @param {string} filePath
719
+ * @returns {string}
720
+ */
721
+ static #toServedPath( rootPath, filePath ) {
722
+ // Split on the platform separator only: on POSIX a backslash is a legal filename character, not a delimiter.
723
+ return "/" + path.relative( String( rootPath || "" ), String( filePath || "" ) ).split( path.sep ).join( "/" );
724
+ }
725
+
726
+ /**
727
+ * Resolves a `staticCache` configuration block into the policy the `/static` mounts apply, filling in
728
+ * {@link TiWebServer.#STATIC_CACHE_DEFAULTS} per key and rejecting values that cannot be honored. Pure: problems
729
+ * are returned as `warnings` rather than logged, so the caller decides how to surface them and a test can assert
730
+ * on them. Static and exposed for unit testing — not part of the customization surface.
731
+ * <br/>
732
+ * `maxAge` is a whole number of SECONDS, mapping 1:1 onto the `Cache-Control` directive express's `"1y"`-style
733
+ * duration strings are NOT accepted, and are reported rather than silently reinterpreted as milliseconds.
734
+ * <br/>
735
+ * `immutable` is dropped (with a warning) when `maxAge` is 0, because a response that is stale on arrival yet
736
+ * promises never to change is a contradiction. Dropping it fails safe: the misconfiguration costs a revalidation,
737
+ * not a year of unreachable assets.
738
+ *
739
+ * @method
740
+ * @static
741
+ * @param {SettingsStaticCache} [staticCache] The configured block, if any.
742
+ * @returns {{maxAge: number, immutable: boolean, immutablePaths: string[], warnings: string[]}}
743
+ * @public
744
+ */
745
+ static resolveStaticCachePolicy( staticCache ) {
746
+ const defaults = TiWebServer.#STATIC_CACHE_DEFAULTS;
747
+ const config = _.isObjectLike( staticCache ) ? staticCache : {};
748
+ const warnings = [];
749
+
750
+ let maxAge = defaults.maxAge;
751
+ if ( config.maxAge !== undefined ) {
752
+ if ( Number.isInteger( config.maxAge ) && config.maxAge >= 0 ) {
753
+ maxAge = config.maxAge;
754
+ } else {
755
+ warnings.push( `Ignored an invalid 'staticCache.maxAge' value of '${ config.maxAge }'; it must be a whole, non-negative number of seconds (a duration string such as '1y' is not accepted). Using ${ defaults.maxAge } instead.` );
756
+ }
757
+ }
758
+
759
+ let immutable = defaults.immutable;
760
+ if ( config.immutable !== undefined ) {
761
+ if ( typeof config.immutable === "boolean" ) {
762
+ immutable = config.immutable;
763
+ } else {
764
+ warnings.push( `Ignored a non-boolean 'staticCache.immutable' value of '${ config.immutable }'. Using ${ defaults.immutable } instead.` );
765
+ }
766
+ }
767
+ if ( immutable === true && maxAge === 0 ) {
768
+ warnings.push( `Ignored 'staticCache.immutable' because 'staticCache.maxAge' is 0 — a response that is stale on arrival cannot also promise never to change. Set a positive 'staticCache.maxAge' (and hash your asset filenames) to serve '/static' as immutable.` );
769
+ immutable = false;
770
+ }
771
+
772
+ let immutablePaths = defaults.immutablePaths.slice();
773
+ if ( config.immutablePaths !== undefined ) {
774
+ if ( Array.isArray( config.immutablePaths ) ) {
775
+ immutablePaths = [];
776
+ config.immutablePaths.forEach( ( entry ) => {
777
+ const normalized = TiWebServer.#normalizeImmutablePath( entry );
778
+ if ( normalized === null ) {
779
+ warnings.push( `Ignored an invalid 'staticCache.immutablePaths' entry of type '${ typeof entry }'; expected a non-empty path prefix such as '/fonts/'.` );
780
+ } else {
781
+ immutablePaths.push( normalized );
782
+ }
783
+ } );
784
+ } else {
785
+ warnings.push( `Ignored a non-array 'staticCache.immutablePaths' value of type '${ typeof config.immutablePaths }'. Using the default [ ${ defaults.immutablePaths.join( ", " ) } ] instead.` );
786
+ }
787
+ }
788
+
789
+ return { maxAge: maxAge, immutable: immutable, immutablePaths: immutablePaths, warnings: warnings };
790
+ }
791
+
792
+ /**
793
+ * Builds the `Cache-Control` value for one static file: the long-lived immutable policy when its served path sits
794
+ * under a configured `immutablePaths` prefix (matched case-sensitively, so a case mismatch falls back to the safe
795
+ * side), otherwise the policy's own `maxAge`/`immutable`. A `maxAge` of 0 is emitted as an explicit
796
+ * `must-revalidate` rather than a bare `max-age=0`, matching what the sibling `web-content` package serves.
797
+ * Pure and static; exposed for unit testing — not part of the customization surface.
798
+ *
799
+ * @method
800
+ * @static
801
+ * @param {string} rootPath The directory this `/static` mount serves.
802
+ * @param {string} filePath The absolute path of the file being served.
803
+ * @param {Object} policy A policy as returned by {@link TiWebServer.resolveStaticCachePolicy}.
804
+ * @returns {string}
805
+ * @public
806
+ */
807
+ static staticCacheControlFor( rootPath, filePath, policy ) {
808
+ const resolved = _.isObjectLike( policy ) ? policy : {};
809
+ const immutablePaths = Array.isArray( resolved.immutablePaths ) ? resolved.immutablePaths : [];
810
+ const servedPath = TiWebServer.#toServedPath( rootPath, filePath );
811
+
812
+ if ( immutablePaths.some( ( prefix ) => servedPath.startsWith( prefix ) ) === true ) {
813
+ return `public, max-age=${ TiWebServer.#IMMUTABLE_MAX_AGE }, immutable`;
814
+ }
815
+
816
+ const maxAge = ( Number.isInteger( resolved.maxAge ) && resolved.maxAge >= 0 ) ? resolved.maxAge : 0;
817
+ if ( maxAge === 0 ) {
818
+ return "public, max-age=0, must-revalidate";
819
+ }
820
+ return ( resolved.immutable === true ) ? `public, max-age=${ maxAge }, immutable` : `public, max-age=${ maxAge }`;
821
+ }
822
+
823
+ /**
824
+ * Normalizes an HTTP method to a lower-case Express routing verb, or returns null if it is not a supported,
825
+ * registrable verb. Anything that is not a string is rejected outright rather than coerced otherwise a value
826
+ * whose `toString()` happens to yield a verb (`[ "get" ]`, `new String( "get" )`) would register a route and
827
+ * bypass the `E_GEN_INVALID_ARGUMENT_TYPE` that {@link TiWebServer#registerRoute} raises for a bad method.
828
+ * Pure and static; exposed for unit testing — not part of the customization surface.
829
+ *
830
+ * @method
831
+ * @static
832
+ * @param {string} method
833
+ * @returns {string|null}
834
+ * @public
835
+ */
836
+ static normalizeRegistrableMethod( method ) {
837
+ if ( typeof method !== "string" ) {
838
+ return null;
839
+ }
840
+ const verb = method.trim().toLowerCase();
841
+ return TiWebServer.#REGISTRABLE_METHODS.has( verb ) ? verb : null;
842
+ }
843
+
844
+ /**
845
+ * Tests a request path against a list of unprotected-route patterns (string exact-match or RegExp test),
846
+ * returning true on the first match. A RegExp's `lastIndex` is reset defensively so a stateful 'g'/'y' flag
847
+ * cannot cause a match to be skipped. Pure and static; shared by {@link TiWebServer#isUnprotectedRoute} and
848
+ * exposed for unit testing — not part of the customization surface.
849
+ *
850
+ * @method
851
+ * @static
852
+ * @param {Array<string|RegExp>} patterns
853
+ * @param {string} pathOnly The request path with any query string already stripped.
854
+ * @returns {boolean}
855
+ * @public
856
+ */
857
+ static isRouteInList( patterns, pathOnly ) {
858
+ for ( let idx = 0; idx < patterns.length; idx++ ) {
859
+ const pattern = patterns[ idx ];
860
+ let matched;
861
+ if ( _.isRegExp( pattern ) ) {
862
+ // Avoid stateful RegExp behavior when 'g' or 'y' flags are present:
863
+ pattern.lastIndex = 0;
864
+ matched = pattern.test( pathOnly );
865
+ } else {
866
+ matched = ( pattern === pathOnly );
867
+ }
868
+ if ( matched === true ) {
869
+ return true;
870
+ }
871
+ }
872
+ return false;
873
+ }
874
+
875
+ /* Private interface */
876
+
877
+ /**
878
+ * Used to start listening for requests on the specified port and host and on the specified server.
879
+ *
880
+ * @method
881
+ * @param {NodeServer} server The server instance to listen on.
882
+ * @param {number} port The port to listen on.
883
+ * @param {string} host The host to listen on.
884
+ * @returns {Promise<NodeServer>}
885
+ */
886
+ #beginListening( server, port, host ) {
887
+ return new Promise( ( resolve, reject ) => {
888
+ server.once( "error", ( error ) => {
889
+ reject( exceptions.raise( error ) );
890
+ } );
891
+ server.once( "listening", () => {
892
+ resolve( server );
893
+ } );
894
+ server.listen( port, host );
895
+ } );
896
+ }
897
+
898
+ /**
899
+ * Used to stop listening for requests on the specified server.
900
+ *
901
+ * @method
902
+ * @param {NodeServer} server The server instance to stop listening on.
903
+ * @returns {Promise}
904
+ */
905
+ #endListening( server ) {
906
+ return new Promise( ( resolve, reject ) => {
907
+ if ( !server ) {
908
+ resolve();
909
+ } else {
910
+ // Close all connections after a short delay to allow all requests to complete:
911
+ setTimeout( () => {
912
+ if ( typeof server.closeIdleConnections === "function" ) {
913
+ server.closeIdleConnections();
914
+ }
915
+ if ( typeof server.closeAllConnections === "function" ) {
916
+ server.closeAllConnections();
917
+ }
918
+ }, 1000 );
919
+
920
+ server.close( ( error ) => {
921
+ if ( error ) {
922
+ reject( exceptions.raise( error ) );
923
+ } else {
924
+ resolve();
925
+ }
926
+ } );
927
+ }
928
+ } );
929
+ }
930
+
931
+ }
932
+
933
+ module.exports = TiWebServer;
934
+ // Exported for unit testing of the ReDoS-hardened matchers; not part of the customization surface.
935
+ TiWebServer.RE_STATIC_UNPROTECTED = RE_STATIC_UNPROTECTED;
936
+ TiWebServer.RE_WELL_KNOWN_UNPROTECTED = RE_WELL_KNOWN_UNPROTECTED;