@ti-engine/web-framework 1.13.1

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 (42) hide show
  1. package/.env +4 -0
  2. package/CHANGELOG.md +274 -0
  3. package/README.md +26 -0
  4. package/bin/build/post-install.js +18 -0
  5. package/bin/localization/web-server-labels.json +28 -0
  6. package/bin/static/.well-known/appspecific/com.chrome.devtools.json +6 -0
  7. package/bin/static/favicon.ico +0 -0
  8. package/bin/static/fragments/components/component-notification-bar.html +21 -0
  9. package/bin/static/fragments/components/component-sidebar-flyout.html +37 -0
  10. package/bin/static/fragments/components/component-sidebar.html +33 -0
  11. package/bin/static/fragments/components/component-tooltip.html +11 -0
  12. package/bin/static/fragments/components/component-topbar.html +6 -0
  13. package/bin/static/fragments/frame-administration.html +3 -0
  14. package/bin/static/fragments/frame-application.html +19 -0
  15. package/bin/static/fragments/frame-dashboard.html +3 -0
  16. package/bin/static/fragments/frame-login.html +105 -0
  17. package/bin/static/fragments/frame-not-found.html +3 -0
  18. package/bin/static/fragments/frame-profile.html +3 -0
  19. package/bin/static/index.html +23 -0
  20. package/bin/static/scripts/lib/alpinejs-csp.min.js +7 -0
  21. package/bin/static/scripts/lib/htmx.min.js +1 -0
  22. package/bin/static/scripts/lib/safe-nonce.min.js +1 -0
  23. package/bin/static/scripts/ti-charts.js +1591 -0
  24. package/bin/static/scripts/ti-framework.css +3195 -0
  25. package/bin/static/scripts/ti-framework.js +1427 -0
  26. package/bin/static/scripts/ti-theme-black-glass.css +216 -0
  27. package/bin/static/scripts/ti-theme-daylight.css +87 -0
  28. package/bin/web-app-manager.js +564 -0
  29. package/bin/web-server.js +604 -0
  30. package/bin/web-server.json +49 -0
  31. package/components/admin-config-handlers.js +92 -0
  32. package/components/auth-manager.js +344 -0
  33. package/components/authorization.js +135 -0
  34. package/components/config-change-notifier.js +98 -0
  35. package/components/config-registry.js +246 -0
  36. package/components/config-service.js +349 -0
  37. package/components/config-store.js +246 -0
  38. package/components/definitions.types.js +26 -0
  39. package/components/session-store.js +111 -0
  40. package/components/user.js +133 -0
  41. package/components/web-handlers.js +765 -0
  42. package/package.json +66 -0
@@ -0,0 +1,92 @@
1
+ /*
2
+ * The ti-engine is an open source, free to use—both for personal and commercial projects—framework for the creation of microservice-based solutions using node.js.
3
+ * Copyright © 2021-2026 Boris Kostadinov <kostadinov.boris@gmail.com>
4
+ * This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
5
+ * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
6
+ * You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
7
+ */
8
+
9
+ /**
10
+ * Express handler factories for the admin configuration-management API. Each factory takes the {@link ConfigService}
11
+ * to delegate to (injected so the handlers are unit-testable), and returns an Express middleware that responds in the
12
+ * framework's convention — `{ isSuccessful: true, data }` on success — forwarding errors to the error middleware.
13
+ * Validation failures come back as `data` (`{ ok:false, errors }`) so the UI can render field-level messages; a
14
+ * version conflict maps to `409` and an unknown editor/document/change-set to `404`. These routes are gated by the
15
+ * `requireAdmin` guard and inherit the server's global auth + CSRF middleware.
16
+ *
17
+ * @module admin-config-handlers
18
+ */
19
+
20
+ const exceptions = require( "@ti-engine/core/exceptions" );
21
+
22
+ function sendData( response, data ) {
23
+ response.set( "Cache-Control", "no-store" );
24
+ response.set( "Content-Type", "application/json; charset=utf-8" );
25
+ response.status( exceptions.httpCode.C_200 ).send( { isSuccessful: true, data: data } );
26
+ }
27
+
28
+ function forward( next, error ) {
29
+ const reason = error && error.data && error.data.reason;
30
+ const raised = exceptions.raise( error );
31
+ if ( reason === "version-conflict" ) {
32
+ raised.httpCode = exceptions.httpCode.C_409;
33
+ } else if ( reason === "unknown-editor" || reason === "unknown-changeset" || reason === "unknown-config" ) {
34
+ raised.httpCode = exceptions.httpCode.C_404;
35
+ }
36
+ next( raised );
37
+ }
38
+
39
+ function adminID( request ) {
40
+ return ( request.session && request.session.user ) ? request.session.user.userID : undefined;
41
+ }
42
+
43
+ /**
44
+ * @param {ConfigService} service
45
+ * @returns {ExpressHandler}
46
+ */
47
+ module.exports.listEditors = ( service ) => ( request, response, next ) => {
48
+ try {
49
+ sendData( response, service.listEditors() );
50
+ } catch ( error ) {
51
+ forward( next, error );
52
+ }
53
+ };
54
+
55
+ module.exports.composeView = ( service ) => ( request, response, next ) => {
56
+ service.composeView( request.params.editorKey ).then( ( view ) => sendData( response, view ) ).catch( ( error ) => forward( next, error ) );
57
+ };
58
+
59
+ module.exports.saveEditorEdit = ( service ) => ( request, response, next ) => {
60
+ const body = request.body || {};
61
+ service.saveEditorEdit( request.params.editorKey, body.edited, { adminID: adminID( request ), note: body.note }, body.expectedVersions ).then( ( result ) => sendData( response, result ) ).catch( ( error ) => forward( next, error ) );
62
+ };
63
+
64
+ module.exports.getCurrent = ( service ) => ( request, response, next ) => {
65
+ service.getCurrent( request.params.configKey ).then( ( current ) => sendData( response, current ) ).catch( ( error ) => forward( next, error ) );
66
+ };
67
+
68
+ module.exports.getHistory = ( service ) => ( request, response, next ) => {
69
+ service.getHistory( request.params.configKey ).then( ( history ) => sendData( response, history ) ).catch( ( error ) => forward( next, error ) );
70
+ };
71
+
72
+ module.exports.listChanges = ( service ) => ( request, response, next ) => {
73
+ service.listChanges().then( ( changes ) => sendData( response, changes ) ).catch( ( error ) => forward( next, error ) );
74
+ };
75
+
76
+ module.exports.getChange = ( service ) => ( request, response, next ) => {
77
+ service.getChange( request.params.changeSetID ).then( ( change ) => sendData( response, change ) ).catch( ( error ) => forward( next, error ) );
78
+ };
79
+
80
+ module.exports.restoreChangeSet = ( service ) => ( request, response, next ) => {
81
+ const body = request.body || {};
82
+ service.restoreChangeSet( request.params.changeSetID, { adminID: adminID( request ), note: body.note } ).then( ( result ) => sendData( response, result ) ).catch( ( error ) => forward( next, error ) );
83
+ };
84
+
85
+ module.exports.exportBundle = ( service ) => ( request, response, next ) => {
86
+ service.exportBundle( { adminID: adminID( request ) } ).then( ( bundle ) => {
87
+ response.set( "Cache-Control", "no-store" );
88
+ response.set( "Content-Type", "application/json; charset=utf-8" );
89
+ response.set( "Content-Disposition", "attachment; filename=\"config-export.json\"" );
90
+ response.status( exceptions.httpCode.C_200 ).send( JSON.stringify( bundle, null, 2 ) );
91
+ } ).catch( ( error ) => forward( next, error ) );
92
+ };
@@ -0,0 +1,344 @@
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 tools = require( "@ti-engine/core/tools" );
10
+ const logger = require( "@ti-engine/core/logger" );
11
+ const exceptions = require( "@ti-engine/core/exceptions" );
12
+ const { randomBytes } = require( "node:crypto" );
13
+ const openidClient = require( "openid-client" );
14
+ const User = require( "#user" );
15
+
16
+ /**
17
+ * Enum for specifying the authentication method.
18
+ *
19
+ * @readonly
20
+ * @enum {string}
21
+ * @typedef {string} TiAuthMethod
22
+ */
23
+ const authMethodEnum = tools.enum( {
24
+ LOCAL: [ "local", "local", "Local authentication with username and password." ],
25
+ OPENID_AZURE: [ "openid-azure", "openid-azure", "Authentication to Azure Cloud using OpenID Connect." ],
26
+ OPENID_GOOGLE: [ "openid-google", "openid-google", "Authentication to Google Cloud using OpenID Connect." ]
27
+ } );
28
+
29
+ /**
30
+ * Enum for specifying the OpenID Connect client authentication method.
31
+ *
32
+ * @readonly
33
+ * @enum {string}
34
+ * @typedef {string} TiTokenEndpointAuthMethod
35
+ */
36
+ const openIDTokenEndpointAuthMethodEnum = tools.enum( {
37
+ BASIC: [ "client_secret_basic", "basic", "Uses 'client_secret_basic' token endpoint authentication method." ],
38
+ POST: [ "client_secret_post", "post", "Uses 'client_secret_post' token endpoint authentication method." ],
39
+ NONE: [ "none", "none", "Uses 'none' token endpoint authentication method." ]
40
+ } );
41
+
42
+ /**
43
+ * The AuthManager class is used to manage authentication and authorization.
44
+ *
45
+ * @class AuthManager
46
+ * @public
47
+ */
48
+ class AuthManager {
49
+
50
+ #initialized = false;
51
+ /** @type {SettingsAuth} */
52
+ #authSettings = {
53
+ enabledMethods: [],
54
+ local: {
55
+ username: undefined,
56
+ password: undefined
57
+ },
58
+ oauth2: {}
59
+ };
60
+ #clientConfigOAuth2Google = {};
61
+ #clientConfigOAuth2Azure = {};
62
+
63
+ /**
64
+ * @constructor
65
+ * @param {SettingsAuth} settings
66
+ */
67
+ constructor( settings ) {
68
+ if ( settings ) {
69
+ this.#authSettings = settings;
70
+ }
71
+
72
+ // Set up local authentication configuration:
73
+ if ( this.isAuthEnabled( authMethodEnum.LOCAL ) ) {
74
+ // TODO: For testing purposes only! Implement real local auth later!
75
+ this.#authSettings.local = this.#authSettings.local || {};
76
+ this.#authSettings.local.username = "admin";
77
+ this.#authSettings.local.password = "admin";
78
+ }
79
+
80
+ // Set up OAuth2 configuration:
81
+ this.#authSettings.oauth2 = this.#authSettings.oauth2 || {};
82
+ if ( this.isAuthEnabled( authMethodEnum.OPENID_GOOGLE ) ) {
83
+ this.#authSettings.oauth2.google = this.#authSettings.oauth2.google || {};
84
+ this.#authSettings.oauth2.google.clientID = process.env.TI_GCLOUD_AUTH_CLIENT_ID || this.#authSettings.oauth2.google.clientID;
85
+ this.#authSettings.oauth2.google.clientSecret = process.env.TI_GCLOUD_AUTH_CLIENT_SECRET || this.#authSettings.oauth2.google.clientSecret;
86
+ this.#authSettings.oauth2.google.callbackUrl = process.env.TI_GCLOUD_AUTH_CALLBACK_URL || this.#authSettings.oauth2.google.callbackUrl;
87
+ this.#authSettings.oauth2.google.discoveryUrl = process.env.TI_GCLOUD_AUTH_DISCOVERY_URL || this.#authSettings.oauth2.google.discoveryUrl;
88
+ }
89
+ if ( this.isAuthEnabled( authMethodEnum.OPENID_AZURE ) ) {
90
+ this.#authSettings.oauth2.azure = this.#authSettings.oauth2.azure || {};
91
+ this.#authSettings.oauth2.azure.clientID = process.env.TI_AZURE_AUTH_CLIENT_ID || this.#authSettings.oauth2.azure.clientID;
92
+ this.#authSettings.oauth2.azure.clientSecret = process.env.TI_AZURE_AUTH_CLIENT_SECRET || this.#authSettings.oauth2.azure.clientSecret;
93
+ this.#authSettings.oauth2.azure.callbackUrl = process.env.TI_AZURE_AUTH_CALLBACK_URL || this.#authSettings.oauth2.azure.callbackUrl;
94
+ this.#authSettings.oauth2.azure.discoveryUrl = process.env.TI_AZURE_AUTH_DISCOVERY_URL || this.#authSettings.oauth2.azure.discoveryUrl;
95
+ }
96
+ }
97
+
98
+ /* Public interface */
99
+
100
+ /**
101
+ * Used to initialize the authentication manager.
102
+ *
103
+ * @method
104
+ * @returns {Promise}
105
+ * @public
106
+ */
107
+ initialize() {
108
+ let promises = [];
109
+ if ( this.isAuthEnabled( authMethodEnum.OPENID_GOOGLE ) ) {
110
+ promises.push( this.#initializeOpenIDClient( this.#authSettings.oauth2.google ).then( ( configuration ) => {
111
+ this.#clientConfigOAuth2Google = configuration;
112
+ logger.log( "Enabled OpenID Connect authentication with Google Cloud.", logger.logSeverity.NOTICE );
113
+ } ) );
114
+ }
115
+ if ( this.isAuthEnabled( authMethodEnum.OPENID_AZURE ) ) {
116
+ promises.push( this.#initializeOpenIDClient( this.#authSettings.oauth2.azure ).then( ( configuration ) => {
117
+ this.#clientConfigOAuth2Azure = configuration;
118
+ logger.log( "Enabled OpenID Connect authentication with Azure Cloud.", logger.logSeverity.NOTICE );
119
+ } ) );
120
+ }
121
+
122
+ return Promise.all( promises ).then( () => {
123
+ this.#initialized = true;
124
+ } );
125
+ }
126
+
127
+ /**
128
+ * Used to check whether the specified authentication method is enabled.
129
+ *
130
+ * @method
131
+ * @param {TiAuthMethod} authMethod
132
+ * @returns {boolean}
133
+ * @public
134
+ */
135
+ isAuthEnabled( authMethod ) {
136
+ return this.#authSettings.enabledMethods.includes( authMethod );
137
+ }
138
+
139
+ /**
140
+ * Used to authenticate a user via the specified authentication method.
141
+ *
142
+ * @method
143
+ * @param {TiAuthMethod} authMethod
144
+ * @param {Object} authDetails
145
+ * @returns {Promise<Object>}
146
+ * @throws {TiException.E_SEC_UNRECOGNIZED_AUTH_METHOD} If the authentication method is not recognized or enabled.
147
+ * @throws {TiException.E_GEN_NOT_INITIALIZED} If the auth manager was not properly initialized.
148
+ * @public
149
+ */
150
+ authenticate( authMethod, authDetails ) {
151
+ if ( !this.#initialized ) {
152
+ throw exceptions.raise( exceptions.exceptionCode.E_GEN_NOT_INITIALIZED );
153
+ }
154
+ switch ( authMethod ) {
155
+ case authMethodEnum.LOCAL:
156
+ return this.#authenticateLocal( authDetails.username, authDetails.password );
157
+ case authMethodEnum.OPENID_GOOGLE:
158
+ return this.#authenticateOpenID( authDetails.baseUrl, this.#authSettings.oauth2.google, this.#clientConfigOAuth2Google );
159
+ case authMethodEnum.OPENID_AZURE:
160
+ return this.#authenticateOpenID( authDetails.baseUrl, this.#authSettings.oauth2.azure, this.#clientConfigOAuth2Azure );
161
+ default: {
162
+ throw exceptions.raise( exceptions.exceptionCode.E_SEC_UNRECOGNIZED_AUTH_METHOD );
163
+ }
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Used to set up user authorization according to the specified authentication method.
169
+ *
170
+ * @method
171
+ * @param {TiAuthMethod} authMethod
172
+ * @param {URL} currentUrl
173
+ * @param {Object} oidc
174
+ * @returns {Promise<User>}
175
+ * @throws {TiException.E_SEC_UNRECOGNIZED_AUTH_METHOD} If the authentication method is not recognized.
176
+ * @public
177
+ */
178
+ authorize( authMethod, currentUrl, oidc ) {
179
+ switch ( authMethod ) {
180
+ case authMethodEnum.LOCAL:
181
+ return Promise.resolve( new User( { userID: `local:${ tools.getUUID() }`, username: oidc.username } ) );
182
+ case authMethodEnum.OPENID_GOOGLE:
183
+ return this.#authorizeOpenID( currentUrl, oidc, this.#clientConfigOAuth2Google );
184
+ case authMethodEnum.OPENID_AZURE:
185
+ return this.#authorizeOpenID( currentUrl, oidc, this.#clientConfigOAuth2Azure );
186
+ default: {
187
+ throw exceptions.raise( exceptions.exceptionCode.E_SEC_UNRECOGNIZED_AUTH_METHOD );
188
+ }
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Used to get the callback URL for the specified OAuth2 authentication method.
194
+ *
195
+ * @method
196
+ * @param {TiAuthMethod} authMethod
197
+ * @returns {string}
198
+ * @throws {TiException.E_SEC_UNRECOGNIZED_AUTH_METHOD} If the requested OAuth2 method is not recognized or enabled.
199
+ * @public
200
+ */
201
+ getOAuth2CallbackUrl( authMethod ) {
202
+ if ( authMethod === authMethodEnum.OPENID_GOOGLE && this.isAuthEnabled( authMethodEnum.OPENID_GOOGLE ) ) {
203
+ return this.#authSettings.oauth2.google.callbackUrl;
204
+ } else if ( authMethod === authMethodEnum.OPENID_AZURE && this.isAuthEnabled( authMethodEnum.OPENID_AZURE ) ) {
205
+ return this.#authSettings.oauth2.azure.callbackUrl;
206
+ } else {
207
+ throw exceptions.raise( exceptions.exceptionCode.E_SEC_UNRECOGNIZED_AUTH_METHOD );
208
+ }
209
+ }
210
+
211
+ /* Private interface */
212
+
213
+ /**
214
+ * Used to initialize the OpenID Connect client for the specified OAuth2 authentication method.
215
+ * <br/>
216
+ * NOTE: A Google Cloud guide available here: https://developers.google.com/identity/openid-connect/openid-connect
217
+ * <br/>
218
+ * NOTE: An Azure Cloud guide available here: https://learn.microsoft.com/en-us/entra/identity-platform/v2-protocols
219
+ *
220
+ * @method
221
+ * @param {SettingsOAuth2Client} oauth2
222
+ * @returns {Promise<openidClient.Configuration>}
223
+ * @private
224
+ */
225
+ #initializeOpenIDClient( oauth2 ) {
226
+ return new Promise( ( resolve, reject ) => {
227
+ // TODO: Public clients are not fully supported yet!
228
+ let clientAuthentication;
229
+ let metaData;
230
+ if ( oauth2.isPublic === true ) {
231
+ metaData = { token_endpoint_auth_method: openIDTokenEndpointAuthMethodEnum.NONE };
232
+ clientAuthentication = openidClient.None();
233
+ } else {
234
+ const method = oauth2.tokenEndpointAuthMethod || openIDTokenEndpointAuthMethodEnum.POST;
235
+ switch ( method ) {
236
+ case openIDTokenEndpointAuthMethodEnum.POST: {
237
+ clientAuthentication = openidClient.ClientSecretPost( oauth2.clientSecret );
238
+ metaData = { token_endpoint_auth_method: openIDTokenEndpointAuthMethodEnum.POST };
239
+ }
240
+ break;
241
+ case openIDTokenEndpointAuthMethodEnum.BASIC: {
242
+ clientAuthentication = openidClient.ClientSecretBasic( oauth2.clientSecret );
243
+ metaData = { token_endpoint_auth_method: openIDTokenEndpointAuthMethodEnum.BASIC };
244
+ }
245
+ break;
246
+ default: {
247
+ return reject( exceptions.raise( exceptions.exceptionCode.E_SEC_UNRECOGNIZED_AUTH_METHOD ) );
248
+ }
249
+ }
250
+ }
251
+
252
+ openidClient.discovery( new URL( oauth2.discoveryUrl ), oauth2.clientID, metaData, clientAuthentication, { algorithm: "oidc" } ).then( ( configuration ) => {
253
+ resolve( configuration );
254
+ } ).catch( ( error ) => {
255
+ reject( exceptions.raise( error ) );
256
+ } );
257
+ } );
258
+ }
259
+
260
+ /**
261
+ * Used to verify the local authentication of a request.
262
+ *
263
+ * @method
264
+ * @param {string} username
265
+ * @param {string} password
266
+ * @returns {Promise}
267
+ * @private
268
+ */
269
+ #authenticateLocal( username, password ) {
270
+ return new Promise( ( resolve, reject ) => {
271
+ // TODO: Implement this!
272
+ if ( this.isAuthEnabled( authMethodEnum.LOCAL ) && ( username === this.#authSettings.local.username && password === this.#authSettings.local.password ) ) {
273
+ resolve();
274
+ } else {
275
+ reject( exceptions.raise( exceptions.exceptionCode.E_SEC_UNAUTHORIZED_ACCESS, null, exceptions.httpCode.C_401 ) );
276
+ }
277
+ } );
278
+ }
279
+
280
+ /**
281
+ * Used to perform the actual OpenID Connect authentication.
282
+ *
283
+ * @method
284
+ * @param {string} baseUrl
285
+ * @param {SettingsOAuth2Client} oauth2
286
+ * @param {openidClient.Configuration} clientConfig
287
+ * @returns {Promise<Object>}
288
+ * @private
289
+ */
290
+ #authenticateOpenID( baseUrl, oauth2, clientConfig ) {
291
+ return new Promise( ( resolve, reject ) => {
292
+ const codeVerifier = openidClient.randomPKCECodeVerifier();
293
+ const nonce = ( typeof openidClient.randomNonce === "function" ) ? openidClient.randomNonce() : randomBytes( 16 ).toString( "base64" );
294
+ const redirectUri = new URL( oauth2.callbackUrl, baseUrl ).toString();
295
+ openidClient.calculatePKCECodeChallenge( codeVerifier ).then( ( codeChallenge ) => {
296
+ const parameters = {
297
+ redirect_uri: redirectUri,
298
+ response_type: "code",
299
+ scope: "openid email profile",
300
+ state: openidClient.randomState(),
301
+ code_challenge: codeChallenge,
302
+ code_challenge_method: "S256",
303
+ nonce: nonce
304
+ };
305
+ const redirectTo = openidClient.buildAuthorizationUrl( clientConfig, parameters );
306
+ resolve( { redirectTo: redirectTo, codeVerifier: codeVerifier, state: parameters.state, nonce: nonce } );
307
+ } ).catch( ( error ) => {
308
+ reject( exceptions.raise( error ) );
309
+ } );
310
+ } );
311
+ }
312
+
313
+ /**
314
+ * Used to perform the actual OpenID Connect authorization.
315
+ *
316
+ * @method
317
+ * @param {URL} currentUrl
318
+ * @param {Object} oidc
319
+ * @param {openidClient.Configuration} clientConfig
320
+ * @returns {Promise<User>}
321
+ * @private
322
+ */
323
+ #authorizeOpenID( currentUrl, oidc, clientConfig ) {
324
+ return new Promise( ( resolve, reject ) => {
325
+ openidClient.authorizationCodeGrant( clientConfig, currentUrl, {
326
+ pkceCodeVerifier: oidc.codeVerifier,
327
+ expectedState: oidc.state,
328
+ expectedNonce: oidc.nonce
329
+ } ).then( ( token ) => {
330
+ const claims = token.claims();
331
+ return openidClient.fetchUserInfo( clientConfig, token.access_token, claims.sub );
332
+ } ).then( ( userInfo ) => {
333
+ const username = userInfo.preferred_username ?? userInfo.email ?? userInfo.name ?? `sub:${ userInfo.sub }`;
334
+ resolve( new User( { userID: `oauth2:${ userInfo.sub }`, username: username, email: userInfo.email, name: userInfo.name } ) );
335
+ } ).catch( ( error ) => {
336
+ reject( exceptions.raise( error ) );
337
+ } );
338
+ } );
339
+ }
340
+
341
+ }
342
+
343
+ module.exports = AuthManager;
344
+ module.exports.authMethod = authMethodEnum;
@@ -0,0 +1,135 @@
1
+ /*
2
+ * The ti-engine is an open source, free to use—both for personal and commercial projects—framework for the creation of microservice-based solutions using node.js.
3
+ * Copyright © 2021-2026 Boris Kostadinov <kostadinov.boris@gmail.com>
4
+ * This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
5
+ * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
6
+ * You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
7
+ */
8
+
9
+ /**
10
+ * Framework-level authorization helpers. Provides the privileged `admin` role used to gate configuration-editing
11
+ * (and other administrative) routes, plus Express guards. The `admin` role is sourced from a deployment allowlist
12
+ * (`auth.admins` in the web-server config) and applied to the session *after* the application's `augmentSession`
13
+ * hook runs, so it is additive and cannot be clobbered by an app's own (domain) role assignment.
14
+ *
15
+ * @module authorization
16
+ */
17
+
18
+ const exceptions = require( "@ti-engine/core/exceptions" );
19
+
20
+ /**
21
+ * The privileged role required to administer configuration. A string value so it never collides with an
22
+ * application's own role codes (e.g. competence uses numeric role codes).
23
+ *
24
+ * @type {string}
25
+ */
26
+ const ADMIN_ROLE = "admin";
27
+
28
+ /**
29
+ * @param {*} value
30
+ * @returns {string} Trimmed, lower-cased string form (so email/username matching is case-insensitive).
31
+ */
32
+ function normalizeIdentity( value ) {
33
+ return String( value == null ? "" : value ).trim().toLowerCase();
34
+ }
35
+
36
+ /**
37
+ * Returns `true` if the user matches any entry in the admin allowlist. An entry may match the user's `userID`,
38
+ * `username`, or `email` (case-insensitive).
39
+ *
40
+ * @param {Object} user A session user (`{ userID, username, email, roles, ... }`).
41
+ * @param {string[]} admins The configured allowlist of admin identifiers.
42
+ * @returns {boolean}
43
+ */
44
+ function isAdminIdentity( user, admins ) {
45
+ if ( !user || !Array.isArray( admins ) || admins.length === 0 ) {
46
+ return false;
47
+ }
48
+ const candidates = new Set( [ user.userID, user.username, user.email ].map( normalizeIdentity ).filter( ( value ) => value.length > 0 ) );
49
+ return admins.some( ( entry ) => candidates.has( normalizeIdentity( entry ) ) );
50
+ }
51
+
52
+ /**
53
+ * Adds the `admin` role to the session user (additively, no duplicates) when the user is in the allowlist.
54
+ * Safe to call with an empty/missing allowlist or session — it is then a no-op. Returns the session for chaining.
55
+ *
56
+ * @param {Object} session
57
+ * @param {string[]} [admins]
58
+ * @returns {Object} The (possibly modified) session.
59
+ */
60
+ function applyAdminRole( session, admins ) {
61
+ if ( session && session.user && isAdminIdentity( session.user, admins ) ) {
62
+ const roles = Array.isArray( session.user.roles ) ? session.user.roles.slice() : [];
63
+ if ( !roles.includes( ADMIN_ROLE ) ) {
64
+ roles.push( ADMIN_ROLE );
65
+ }
66
+ session.user.roles = roles;
67
+ }
68
+ return session;
69
+ }
70
+
71
+ /**
72
+ * @param {Object} session
73
+ * @param {Array<string|number>} roles
74
+ * @returns {boolean} `true` if the session user holds any of the given roles.
75
+ */
76
+ function hasAnyRole( session, roles ) {
77
+ const userRoles = ( session && session.user && Array.isArray( session.user.roles ) ) ? session.user.roles : [];
78
+ return roles.some( ( role ) => userRoles.includes( role ) );
79
+ }
80
+
81
+ /**
82
+ * Pure access decision for a resource (e.g. an HTML fragment) that declares a set of required roles. A resource with
83
+ * no required roles (`null` / `undefined` / empty) is public — any (authenticated) user may access it; otherwise the
84
+ * user must hold at least one of the required roles. Roles are treated opaquely, so this works equally for numeric
85
+ * application role codes and the string `admin` role — there is no implicit hierarchy (an `admin`-gated resource is
86
+ * reachable only by holders of the `admin` role, never by a high numeric role). Backs {@link TiWebAppManager#verifyAccess}.
87
+ *
88
+ * @param {Array<string|number>} [requiredRoles] The roles permitted to access the resource; empty/absent = public.
89
+ * @param {Array<string|number>} [userRoles] The roles held by the current session user.
90
+ * @returns {boolean}
91
+ */
92
+ function isAccessAllowed( requiredRoles, userRoles ) {
93
+ if ( requiredRoles === null || requiredRoles === undefined ) {
94
+ return true;
95
+ }
96
+ if ( !Array.isArray( requiredRoles ) ) {
97
+ return false;
98
+ }
99
+ if ( requiredRoles.length === 0 ) {
100
+ return true;
101
+ }
102
+ const roles = Array.isArray( userRoles ) ? userRoles : [];
103
+ return requiredRoles.some( ( role ) => roles.includes( role ) );
104
+ }
105
+
106
+ /**
107
+ * Express middleware factory that admits a request only if its session user holds at least one of the given roles.
108
+ * Responds `401` when unauthenticated (no session user) and `403` when authenticated but lacking the role.
109
+ *
110
+ * @param {...(string|number)} roles
111
+ * @returns {function(Object, Object, Function): void}
112
+ */
113
+ function requireRole( ...roles ) {
114
+ return ( request, response, next ) => {
115
+ const user = request && request.session && request.session.user;
116
+ if ( !user ) {
117
+ response.status( exceptions.httpCode.C_401 ).end();
118
+ return;
119
+ }
120
+ if ( !hasAnyRole( request.session, roles ) ) {
121
+ response.status( exceptions.httpCode.C_403 ).end();
122
+ return;
123
+ }
124
+ next();
125
+ };
126
+ }
127
+
128
+ /**
129
+ * Express middleware that admits only `admin`-role users.
130
+ *
131
+ * @type {function(Object, Object, Function): void}
132
+ */
133
+ const requireAdmin = requireRole( ADMIN_ROLE );
134
+
135
+ module.exports = { ADMIN_ROLE, isAdminIdentity, applyAdminRole, hasAnyRole, isAccessAllowed, requireRole, requireAdmin };
@@ -0,0 +1,98 @@
1
+ /*
2
+ * The ti-engine is an open source, free to use—both for personal and commercial projects—framework for the creation of microservice-based solutions using node.js.
3
+ * Copyright © 2021-2026 Boris Kostadinov <kostadinov.boris@gmail.com>
4
+ * This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
5
+ * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
6
+ * You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
7
+ */
8
+
9
+ const { EventEmitter } = require( "node:events" );
10
+ const logger = require( "@ti-engine/core/logger" );
11
+
12
+ /**
13
+ * @typedef {Object} ConfigChangeEvent
14
+ * @property {string} changeSetID
15
+ * @property {string[]} configKeys The configuration documents affected by the change.
16
+ * @property {string} adminID Who committed the change.
17
+ * @property {string} timestamp ISO timestamp.
18
+ */
19
+
20
+ const CONFIG_CHANGED = "config:changed";
21
+
22
+ /**
23
+ * Notifies subscribers that configuration changed, so they can react (e.g. invalidate an in-memory cache, or push a
24
+ * live update to an admin UI). This is the **in-process** implementation of a deliberately transport-agnostic
25
+ * contract — `publish(event)` (fire-and-forget) and `subscribe(listener) → unsubscribe`.
26
+ *
27
+ * **Designed for an eventual switch to a reusable core pub/sub.** Cross-instance propagation is out of scope for v1
28
+ * (the store-backed model already makes a committed change visible to every instance via the shared Redis cache;
29
+ * this emitter exists to invalidate optional *in-memory* caches and drive live UI within a process). When a Redis
30
+ * (or other) pub/sub primitive lands in `@ti-engine/core`, a drop-in implementation of this same contract can be
31
+ * provided and injected into {@link ConfigService} — no change to publishers or subscribers. To keep that swap
32
+ * behavior-safe, **delivery here is already asynchronous** (matching cross-instance transports); subscribers must
33
+ * not assume synchronous delivery, and the event payload is plain serializable JSON so it survives a wire transport.
34
+ *
35
+ * @class ConfigChangeNotifier
36
+ * @public
37
+ */
38
+ class ConfigChangeNotifier {
39
+
40
+ #emitter = new EventEmitter();
41
+
42
+ constructor() {
43
+ this.#emitter.setMaxListeners( 0 );
44
+ }
45
+
46
+ /**
47
+ * Publishes a configuration-change event to all subscribers. Fire-and-forget; delivery is asynchronous.
48
+ *
49
+ * @method
50
+ * @param {ConfigChangeEvent} event
51
+ * @returns {ConfigChangeEvent} The (frozen) event that will be delivered.
52
+ * @public
53
+ */
54
+ publish( event ) {
55
+ const payload = Object.freeze( { ...event } );
56
+ setImmediate( () => {
57
+ for ( const listener of this.#emitter.listeners( CONFIG_CHANGED ) ) {
58
+ try {
59
+ listener( payload );
60
+ } catch ( error ) {
61
+ // A misbehaving subscriber must not break delivery to the others or crash the process.
62
+ logger.log( `Config-change subscriber threw: ${ error && error.message ? error.message : error }`, logger.logSeverity.WARNING );
63
+ }
64
+ }
65
+ } );
66
+ return payload;
67
+ }
68
+
69
+ /**
70
+ * Subscribes a listener to configuration-change events.
71
+ *
72
+ * @method
73
+ * @param {function(ConfigChangeEvent): void} listener
74
+ * @returns {function(): void} An unsubscribe function.
75
+ * @public
76
+ */
77
+ subscribe( listener ) {
78
+ this.#emitter.on( CONFIG_CHANGED, listener );
79
+ return () => {
80
+ this.#emitter.off( CONFIG_CHANGED, listener );
81
+ };
82
+ }
83
+
84
+ /**
85
+ * @method
86
+ * @returns {number} The current number of subscribers.
87
+ * @public
88
+ */
89
+ subscriberCount() {
90
+ return this.#emitter.listenerCount( CONFIG_CHANGED );
91
+ }
92
+
93
+ }
94
+
95
+ const instance = new ConfigChangeNotifier();
96
+ module.exports = ConfigChangeNotifier;
97
+ module.exports.instance = instance;
98
+ module.exports.CONFIG_CHANGED = CONFIG_CHANGED;