@ti-engine/web-framework 1.15.0 → 1.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,22 @@
2
2
 
3
3
  This document will contain the list of changes made to the framework. The format is based on the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) specification.
4
4
 
5
+ ## Version 1.17.0
6
+
7
+ Route-registration seams so an application subclass can add its own Express routes and unprotected-route patterns — enabling public, content-driven sites (the first consumer being the standalone author's site) to layer a catch-all content resolver over the framework without reaching into private state.
8
+
9
+ * feat(web-server): add `TiWebServer.registerRoute( method, path, ...handlers )` — registers a custom route on the underlying Express app from a `defineWebApplicationRoutes()` override (after `super()`), so a catch-all resolver can be mounted after the framework's own routes but before its `*splat` 404 handler. Limited to route-scoped verbs (get/post/put/patch/delete/options/head/all); raises `E_GEN_INVALID_ARGUMENT_TYPE` for any other method and `E_GEN_NOT_INITIALIZED` if called before the Express app exists
10
+ * feat(web-server): add `TiWebServer.addUnprotectedRoute( pattern )` — appends a string (exact-match) or RegExp (tested) pattern to the unprotected-routes list from a `defineUnprotectedRoutes()` override, so a public-by-default site can invert the framework's protect-by-default stance; non-string/non-RegExp values are ignored with a warning
11
+ * refactor(web-server): extract the unprotected-route matching loop from `isUnprotectedRoute()` into the pure, unit-tested `isRouteInList()` helper (behavior unchanged, including the defensive `lastIndex` reset), and add the `normalizeRegistrableMethod()` helper — both exported for testing alongside the existing `RE_*` matcher constants
12
+ * build(release): bump package version from `1.16.0` to `1.17.0`
13
+
14
+ ## Version 1.16.0
15
+
16
+ Support explicitly trusted request origins so state-changing requests (e.g. login) work behind proxies that do not present the app's external host — most notably GitHub Codespaces port forwarding (CA-90).
17
+
18
+ * feat(web-framework): add `TI_WEB_TRUSTED_ORIGINS` (comma-separated) / `config.trustedOrigins`. The `originRefererValidationHandler` now accepts a non-GET request whose `Origin`/`Referer` matches the server-reconstructed base URL **or** any configured trusted origin. Previously such a request behind a proxy that rewrote/omitted the forwarded host was rejected with `E_WEB_INVALID_REQUEST_PARAMETERS` (HTTP 403). Backward compatible (empty list = prior behavior); the CSRF double-submit token check is unchanged and still enforced
19
+ * build(release): bump package version from `1.15.0` to `1.16.0`
20
+
5
21
  ## Version 1.15.0
6
22
 
7
23
  A dedicated health endpoint, a `TI_WEB_AUTH_METHODS` env override, and login-page gating for every auth method — completing the container-friendly auth/health story for the competence deployment (CA-90).
@@ -1,37 +1,37 @@
1
- <div x-id="['component-flyout']"
2
- x-data="tiComponentSidebarFlyout('{ti-config-key}')"
3
- class="ti-sidebar-flyout-container">
4
- <!--
5
- Trigger slot. The placeholder consumer can replace this with any custom button — typically a user avatar
6
- or app menu trigger. The trigger MUST set x-ref="flyoutButton" and call toggle() to integrate with the
7
- encapsulated flyout state. If the consumer omits inner content, this default icon button is used.
8
- -->
9
- <ti-slot>
10
- <button x-on:click.stop="toggle()"
11
- x-on:ti-close-all-flyout="close()"
12
- x-bind:aria-expanded="isOpen"
13
- x-bind:aria-controls="$id('component-flyout')"
14
- x-bind:aria-label="menuTitle"
15
- x-ref="flyoutButton"
16
- type="button" class="ti-sidebar-button">
17
- <span class="ti-icon" x-bind:class="icon" aria-hidden="true"></span>
18
- </button>
19
- </ti-slot>
20
- <div x-on:click.outside="close()"
21
- x-bind:id="$id('component-flyout')"
22
- x-show="isOpen"
23
- x-ref="flyoutPanel"
24
- role="menu" class="ti-sidebar-flyout">
25
- <template x-for="(buttonConfig, index) in buttonConfigs">
26
- <button x-on:click="close()"
27
- x-bind:hx-get="(!buttonConfig.action.method || buttonConfig.action.method === 'get') ? buttonConfig.action.href : null"
28
- x-bind:hx-post="(buttonConfig.action.method && buttonConfig.action.method === 'post') ? buttonConfig.action.href : null"
29
- x-bind:hx-target="buttonConfig.action.target"
30
- x-bind:hx-swap="buttonConfig.action.swap"
31
- hx-push-url="true"
32
- type="button" class="ti-sidebar-flyout-item">
33
- <span class="ti-icon" x-bind:class="buttonConfig.icon" aria-hidden="true"></span><span x-text="buttonConfig.title"></span>
34
- </button>
35
- </template>
36
- </div>
1
+ <div x-id="['component-flyout']"
2
+ x-data="tiComponentSidebarFlyout('{ti-config-key}')"
3
+ class="ti-sidebar-flyout-container">
4
+ <!--
5
+ Trigger slot. The placeholder consumer can replace this with any custom button — typically a user avatar
6
+ or app menu trigger. The trigger MUST set x-ref="flyoutButton" and call toggle() to integrate with the
7
+ encapsulated flyout state. If the consumer omits inner content, this default icon button is used.
8
+ -->
9
+ <ti-slot>
10
+ <button x-on:click.stop="toggle()"
11
+ x-on:ti-close-all-flyout="close()"
12
+ x-bind:aria-expanded="isOpen"
13
+ x-bind:aria-controls="$id('component-flyout')"
14
+ x-bind:aria-label="menuTitle"
15
+ x-ref="flyoutButton"
16
+ type="button" class="ti-sidebar-button">
17
+ <span class="ti-icon" x-bind:class="icon" aria-hidden="true"></span>
18
+ </button>
19
+ </ti-slot>
20
+ <div x-on:click.outside="close()"
21
+ x-bind:id="$id('component-flyout')"
22
+ x-show="isOpen"
23
+ x-ref="flyoutPanel"
24
+ role="menu" class="ti-sidebar-flyout">
25
+ <template x-for="(buttonConfig, index) in buttonConfigs">
26
+ <button x-on:click="close()"
27
+ x-bind:hx-get="(!buttonConfig.action.method || buttonConfig.action.method === 'get') ? buttonConfig.action.href : null"
28
+ x-bind:hx-post="(buttonConfig.action.method && buttonConfig.action.method === 'post') ? buttonConfig.action.href : null"
29
+ x-bind:hx-target="buttonConfig.action.target"
30
+ x-bind:hx-swap="buttonConfig.action.swap"
31
+ hx-push-url="true"
32
+ type="button" class="ti-sidebar-flyout-item">
33
+ <span class="ti-icon" x-bind:class="buttonConfig.icon" aria-hidden="true"></span><span x-text="buttonConfig.title"></span>
34
+ </button>
35
+ </template>
36
+ </div>
37
37
  </div>
package/bin/web-server.js CHANGED
@@ -301,7 +301,7 @@ class TiWebServer extends ServiceConsumer {
301
301
  store: new SessionStore()
302
302
  } ) );
303
303
  this.#webServer.use( webHandlers.csrfInitHandler( this ) );
304
- this.#webServer.use( webHandlers.originRefererValidationHandler() );
304
+ this.#webServer.use( webHandlers.originRefererValidationHandler( this ) );
305
305
  this.#webServer.use( webHandlers.csrfProtectionHandler() );
306
306
 
307
307
  // Set up the web server routes:
@@ -493,21 +493,7 @@ class TiWebServer extends ServiceConsumer {
493
493
  */
494
494
  isUnprotectedRoute( route ) {
495
495
  const pathOnly = String( route || "" ).split( "?" )[ 0 ];
496
- let result = false;
497
- for ( let idx = 0; idx < this.#unprotectedRoutes.length; idx++ ) {
498
- const pattern = this.#unprotectedRoutes[ idx ];
499
- if ( _.isRegExp( pattern ) ) {
500
- // Avoid stateful RegExp behavior when 'g' or 'y' flags are present:
501
- pattern.lastIndex = 0;
502
- result = pattern.test( pathOnly );
503
- } else {
504
- result = ( pattern === pathOnly );
505
- }
506
- if ( result ) {
507
- break;
508
- }
509
- }
510
- return result;
496
+ return TiWebServer.isRouteInList( this.#unprotectedRoutes, pathOnly );
511
497
  }
512
498
 
513
499
  /**
@@ -574,6 +560,113 @@ class TiWebServer extends ServiceConsumer {
574
560
  this.#unprotectedRoutes.push( RE_WELL_KNOWN_UNPROTECTED );
575
561
  }
576
562
 
563
+ /**
564
+ * Registers a custom application route on the underlying Express app.
565
+ * <br/>
566
+ * NOTE: Call this from a {@link TiWebServer#defineWebApplicationRoutes} override AFTER invoking the base method,
567
+ * so the framework's own routes keep priority and any catch-all route you add resolves last (it will still be
568
+ * registered before the framework's own `*splat` 404 handler). It is only valid once the Express app exists —
569
+ * i.e., from within {@link TiWebServer#defineWebApplicationRoutes}, which {@link TiWebServer#onStart} invokes.
570
+ *
571
+ * @method
572
+ * @param {string} method One of the supported routing verbs: get, post, put, patch, delete, options, head, all.
573
+ * @param {string|RegExp} path The route path or pattern.
574
+ * @param {...Function} handlers One or more Express route handlers/middleware.
575
+ * @returns {TiWebServer} This instance, to allow chaining.
576
+ * @public
577
+ */
578
+ registerRoute( method, path, ...handlers ) {
579
+ const verb = TiWebServer.normalizeRegistrableMethod( method );
580
+ if ( verb === null ) {
581
+ throw exceptions.raise( exceptions.exceptionCode.E_GEN_INVALID_ARGUMENT_TYPE, { method: method } );
582
+ }
583
+ if ( !this.#webServer ) {
584
+ throw exceptions.raise( exceptions.exceptionCode.E_GEN_NOT_INITIALIZED, { detail: "registerRoute() called before the Express app was created; call it from a defineWebApplicationRoutes() override." } );
585
+ }
586
+ this.#webServer[ verb ]( path, ...handlers );
587
+ return this;
588
+ }
589
+
590
+ /**
591
+ * Adds a pattern to the unprotected-routes list — routes that bypass the authentication gate. A string is
592
+ * matched exactly against the request path; a RegExp is tested against it. Consulted at request time by
593
+ * {@link TiWebServer#isUnprotectedRoute}.
594
+ * <br/>
595
+ * NOTE: Call this from a {@link TiWebServer#defineUnprotectedRoutes} override AFTER invoking the base method, to
596
+ * extend (rather than replace) the defaults.
597
+ *
598
+ * @method
599
+ * @param {string|RegExp} pattern The exact path (string) or path matcher (RegExp) to treat as unprotected.
600
+ * @returns {TiWebServer} This instance, to allow chaining.
601
+ * @public
602
+ */
603
+ addUnprotectedRoute( pattern ) {
604
+ if ( _.isString( pattern ) || _.isRegExp( pattern ) ) {
605
+ this.#unprotectedRoutes.push( pattern );
606
+ } else {
607
+ logger.log( `Ignored an invalid unprotected-route pattern of type '${ typeof pattern }'; expected a string or RegExp.`, logger.logSeverity.WARNING );
608
+ }
609
+ return this;
610
+ }
611
+
612
+ /* Static interface */
613
+
614
+ /**
615
+ * The Express routing verbs that {@link TiWebServer#registerRoute} will register. Deliberately limited to
616
+ * route-scoped methods — `use` (global middleware mounting) is intentionally excluded; add a dedicated seam if
617
+ * middleware mounting is ever needed.
618
+ *
619
+ * @type {Set<string>}
620
+ * @private
621
+ */
622
+ static #REGISTRABLE_METHODS = new Set( [ "get", "post", "put", "patch", "delete", "options", "head", "all" ] );
623
+
624
+ /**
625
+ * Normalizes an HTTP method to a lower-case Express routing verb, or returns null if it is not a supported,
626
+ * registrable verb. Pure and static; exposed for unit testing — not part of the customization surface.
627
+ *
628
+ * @method
629
+ * @static
630
+ * @param {string} method
631
+ * @returns {string|null}
632
+ * @public
633
+ */
634
+ static normalizeRegistrableMethod( method ) {
635
+ const verb = String( method || "" ).trim().toLowerCase();
636
+ return TiWebServer.#REGISTRABLE_METHODS.has( verb ) ? verb : null;
637
+ }
638
+
639
+ /**
640
+ * Tests a request path against a list of unprotected-route patterns (string exact-match or RegExp test),
641
+ * returning true on the first match. A RegExp's `lastIndex` is reset defensively so a stateful 'g'/'y' flag
642
+ * cannot cause a match to be skipped. Pure and static; shared by {@link TiWebServer#isUnprotectedRoute} and
643
+ * exposed for unit testing — not part of the customization surface.
644
+ *
645
+ * @method
646
+ * @static
647
+ * @param {Array<string|RegExp>} patterns
648
+ * @param {string} pathOnly The request path with any query string already stripped.
649
+ * @returns {boolean}
650
+ * @public
651
+ */
652
+ static isRouteInList( patterns, pathOnly ) {
653
+ for ( let idx = 0; idx < patterns.length; idx++ ) {
654
+ const pattern = patterns[ idx ];
655
+ let matched;
656
+ if ( _.isRegExp( pattern ) ) {
657
+ // Avoid stateful RegExp behavior when 'g' or 'y' flags are present:
658
+ pattern.lastIndex = 0;
659
+ matched = pattern.test( pathOnly );
660
+ } else {
661
+ matched = ( pattern === pathOnly );
662
+ }
663
+ if ( matched === true ) {
664
+ return true;
665
+ }
666
+ }
667
+ return false;
668
+ }
669
+
577
670
  /* Private interface */
578
671
 
579
672
  /**
@@ -1,60 +1,64 @@
1
- /*
2
- * The ti-engine is an open source, free to use—both for personal and commercial projects—framework for the creation of microservice-based solutions using node.js.
3
- * Copyright © 2021-2026 Boris Kostadinov <kostadinov.boris@gmail.com>
4
- * This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
5
- * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
6
- * You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
7
- */
8
-
9
- "use strict";
10
-
11
- const tools = require( "@ti-engine/core/tools" );
12
-
13
- /**
14
- * Applies TI_WEB_* environment-variable overrides onto an (already-merged) web server configuration object.
15
- * Each override is applied ONLY when its environment variable is defined, so an absent variable leaves the
16
- * configured/default value untouched (fully backward compatible). This gives ti-engine web servers 12-factor,
17
- * container-friendly control over network binding, TLS, the session cookie secret, and the enabled authentication
18
- * methods without editing config files. Note `TI_WEB_AUTH_METHODS` fully REPLACES `auth.enabledMethods` (a clean
19
- * array replacement) rather than merging — the config-file merge is by-index and cannot cleanly override an array.
20
- *
21
- * @method
22
- * @param {Object} config The web server configuration to augment (mutated in place and returned).
23
- * @param {Object} [env=process.env] The environment source (injectable for testing).
24
- * @returns {Object} The same config object, with any present overrides applied.
25
- * @public
26
- */
27
- function applyWebConfigEnvOverrides( config, env = process.env ) {
28
- if ( !config || typeof config !== "object" ) {
29
- return config;
30
- }
31
- if ( env.TI_WEB_HOST !== undefined ) {
32
- config.host = env.TI_WEB_HOST;
33
- }
34
- if ( env.TI_WEB_PORT !== undefined ) {
35
- const port = Number( env.TI_WEB_PORT );
36
- if ( Number.isInteger( port ) ) {
37
- config.port = port;
38
- }
39
- }
40
- if ( env.TI_WEB_USE_TLS !== undefined ) {
41
- config.useTLS = tools.toBool( env.TI_WEB_USE_TLS );
42
- }
43
- if ( env.TI_WEB_TLS_CERT_PATH !== undefined ) {
44
- config.tlsCertPath = env.TI_WEB_TLS_CERT_PATH;
45
- }
46
- if ( env.TI_WEB_TLS_KEY_PATH !== undefined ) {
47
- config.tlsKeyPath = env.TI_WEB_TLS_KEY_PATH;
48
- }
49
- if ( env.TI_WEB_COOKIE_SECRET !== undefined ) {
50
- config.cookies = config.cookies || {};
51
- config.cookies.secret = env.TI_WEB_COOKIE_SECRET;
52
- }
53
- if ( env.TI_WEB_AUTH_METHODS !== undefined ) {
54
- config.auth = config.auth || {};
55
- config.auth.enabledMethods = env.TI_WEB_AUTH_METHODS.split( "," ).map( ( method ) => method.trim() ).filter( ( method ) => method.length > 0 );
56
- }
57
- return config;
58
- }
59
-
60
- module.exports = applyWebConfigEnvOverrides;
1
+ /*
2
+ * The ti-engine is an open source, free to use—both for personal and commercial projects—framework for the creation of microservice-based solutions using node.js.
3
+ * Copyright © 2021-2026 Boris Kostadinov <kostadinov.boris@gmail.com>
4
+ * This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
5
+ * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
6
+ * You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
7
+ */
8
+
9
+ "use strict";
10
+
11
+ const tools = require( "@ti-engine/core/tools" );
12
+
13
+ /**
14
+ * Applies TI_WEB_* environment-variable overrides onto an (already-merged) web server configuration object.
15
+ * Each override is applied ONLY when its environment variable is defined, so an absent variable leaves the
16
+ * configured/default value untouched (fully backward compatible). This gives ti-engine web servers 12-factor,
17
+ * container-friendly control over network binding, TLS, the session cookie secret, the enabled authentication
18
+ * methods, and the trusted request origins without editing config files. Note `TI_WEB_AUTH_METHODS` and
19
+ * `TI_WEB_TRUSTED_ORIGINS` fully REPLACE their config arrays (`auth.enabledMethods` / `trustedOrigins`) rather than
20
+ * merging — the config-file merge is by-index and cannot cleanly override an array.
21
+ *
22
+ * @method
23
+ * @param {Object} config The web server configuration to augment (mutated in place and returned).
24
+ * @param {Object} [env=process.env] The environment source (injectable for testing).
25
+ * @returns {Object} The same config object, with any present overrides applied.
26
+ * @public
27
+ */
28
+ function applyWebConfigEnvOverrides( config, env = process.env ) {
29
+ if ( !config || typeof config !== "object" ) {
30
+ return config;
31
+ }
32
+ if ( env.TI_WEB_HOST !== undefined ) {
33
+ config.host = env.TI_WEB_HOST;
34
+ }
35
+ if ( env.TI_WEB_PORT !== undefined ) {
36
+ const port = Number( env.TI_WEB_PORT );
37
+ if ( Number.isInteger( port ) ) {
38
+ config.port = port;
39
+ }
40
+ }
41
+ if ( env.TI_WEB_USE_TLS !== undefined ) {
42
+ config.useTLS = tools.toBool( env.TI_WEB_USE_TLS );
43
+ }
44
+ if ( env.TI_WEB_TLS_CERT_PATH !== undefined ) {
45
+ config.tlsCertPath = env.TI_WEB_TLS_CERT_PATH;
46
+ }
47
+ if ( env.TI_WEB_TLS_KEY_PATH !== undefined ) {
48
+ config.tlsKeyPath = env.TI_WEB_TLS_KEY_PATH;
49
+ }
50
+ if ( env.TI_WEB_COOKIE_SECRET !== undefined ) {
51
+ config.cookies = config.cookies || {};
52
+ config.cookies.secret = env.TI_WEB_COOKIE_SECRET;
53
+ }
54
+ if ( env.TI_WEB_AUTH_METHODS !== undefined ) {
55
+ config.auth = config.auth || {};
56
+ config.auth.enabledMethods = env.TI_WEB_AUTH_METHODS.split( "," ).map( ( method ) => method.trim() ).filter( ( method ) => method.length > 0 );
57
+ }
58
+ if ( env.TI_WEB_TRUSTED_ORIGINS !== undefined ) {
59
+ config.trustedOrigins = env.TI_WEB_TRUSTED_ORIGINS.split( "," ).map( ( origin ) => origin.trim() ).filter( ( origin ) => origin.length > 0 );
60
+ }
61
+ return config;
62
+ }
63
+
64
+ module.exports = applyWebConfigEnvOverrides;
@@ -690,19 +690,30 @@ module.exports.webAppHandler = ( instance ) => {
690
690
  * @returns {ExpressHandler}
691
691
  * @public
692
692
  */
693
- module.exports.originRefererValidationHandler = () => {
693
+ module.exports.originRefererValidationHandler = ( instance ) => {
694
694
  return ( request, response, next ) => {
695
695
  if ( request.method === "GET" || request.method === "HEAD" || request.method === "OPTIONS" ) {
696
696
  next();
697
697
  } else {
698
- const expectedOrigin = getBaseUrl( request );
699
698
  const providedOrigin = getRequestOrigin( request );
700
- // If the browser didnt send Origin/Referer (normal for same-origin form POSTs), let CSRF middleware handle protection instead of blocking here:
701
- if ( providedOrigin && String( providedOrigin ).trim().toLowerCase() !== String( expectedOrigin ).trim().toLowerCase() ) {
702
- logger.log( `Issue identified with origin/referer mismatch. Expected '${ expectedOrigin }', received '${ providedOrigin }'.`, logger.logSeverity.WARNING );
703
- next( exceptions.raise( exceptions.exceptionCode.E_WEB_INVALID_REQUEST_PARAMETERS, null, exceptions.httpCode.C_403 ) );
704
- } else {
699
+ // If the browser didn't send Origin/Referer (normal for same-origin form POSTs), let CSRF middleware handle protection instead of blocking here:
700
+ if ( !providedOrigin ) {
705
701
  next();
702
+ } else {
703
+ // Accept the origin the server reconstructs from the request, plus any explicitly trusted origins
704
+ // (TI_WEB_TRUSTED_ORIGINS / config.trustedOrigins). The trusted list is needed behind proxies that do
705
+ // not present the external host to the app (e.g. GitHub Codespaces port forwarding), where the browser
706
+ // Origin cannot be reconstructed from the forwarded headers.
707
+ const configured = ( instance && instance.serviceConfig && Array.isArray( instance.serviceConfig.trustedOrigins ) ) ? instance.serviceConfig.trustedOrigins : [];
708
+ const allowedOrigins = [ getBaseUrl( request ) ].concat( configured );
709
+ const normalizedProvided = String( providedOrigin ).trim().toLowerCase();
710
+ const isAllowed = allowedOrigins.some( ( origin ) => String( origin ).trim().toLowerCase() === normalizedProvided );
711
+ if ( isAllowed ) {
712
+ next();
713
+ } else {
714
+ logger.log( `Issue identified with origin/referer mismatch. Received '${ providedOrigin }'; expected one of [ ${ allowedOrigins.join( ", " ) } ].`, logger.logSeverity.WARNING );
715
+ next( exceptions.raise( exceptions.exceptionCode.E_WEB_INVALID_REQUEST_PARAMETERS, null, exceptions.httpCode.C_403 ) );
716
+ }
706
717
  }
707
718
  }
708
719
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ti-engine/web-framework",
3
- "version": "1.15.0",
3
+ "version": "1.17.0",
4
4
  "description": "A web-framework based on the ti-engine. It provides a customizable ready-to-use web-server microservice and a set of tools for creating web applications. NOTICE: This is still a work in progress and the full architecture, design, and functionality are not available!",
5
5
  "author": "Boris Kostadinov <kostadinov.boris@gmail.com>",
6
6
  "license": "GPL-3.0-or-later",