@ti-engine/web-framework 1.17.0 → 1.19.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 +37 -1
- package/README.md +38 -0
- package/bin/web-server.js +213 -9
- package/components/auth-manager.js +45 -0
- package/components/config-registry.js +18 -4
- package/components/config-service.js +17 -6
- package/components/web-config-env.js +23 -2
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,11 +2,47 @@
|
|
|
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.19.0
|
|
6
|
+
|
|
7
|
+
`/static` was served with `max-age=1y, immutable` for every consumer of the framework. `immutable` is a promise that the bytes behind a URL will never change, and browsers honour it so completely that not even a manual reload revalidates — so the promise is only true for a content-addressed URL (`app.a1b2c3.css`). None of the framework's own assets are named that way (`/static/scripts/ti-framework.js`, the theme sheets), which made this an unsafe default that shipped to npm: a deployed CSS or JS fix would never reach anyone who had already visited, for up to a year, with no way to tell them otherwise. The standalone author's site had worked around it privately by fingerprinting its own asset URLs; every other consumer still inherited the bug.
|
|
8
|
+
|
|
9
|
+
* fix(web-server)!: the default `/static` cache policy is now `public, max-age=0, must-revalidate` instead of `max-age=1y, immutable`, so a deployed asset change actually reaches a returning visitor. `express.static` still attaches an `ETag`/`Last-Modified`, so a revalidation of an unchanged asset is answered with a `304` — headers, no body. **A consumer whose asset filenames are content-addressed should opt back in** with `staticCache: { maxAge: 31536000, immutable: true }`; one that appends a content hash to its asset URLs (rather than to the filenames) is equally entitled to it
|
|
10
|
+
* feat(web-server): add the `staticCache` configuration block — `maxAge` (whole **seconds**, mapping 1:1 onto the `Cache-Control` directive; an express-style `"1y"` duration string is reported rather than silently reinterpreted as milliseconds), `immutable`, and `immutablePaths` (path prefixes served long-lived and `immutable` regardless of the other two). `immutable` combined with a `maxAge` of 0 is a contradiction and is dropped with a warning, so a half-configured deployment costs a revalidation rather than a year of unreachable assets
|
|
11
|
+
* feat(web-server): `staticCache.immutablePaths` defaults to `[ "/fonts/" ]` — a released `.woff2` is an artifact rather than something edited in place, and its filename already carries the family, weight and style. Configurable, and clearable with an explicitly empty array, because that is a statement about how a given deployment manages its font files
|
|
12
|
+
* feat(web-framework): add the `TI_WEB_STATIC_MAX_AGE`, `TI_WEB_STATIC_IMMUTABLE`, and `TI_WEB_STATIC_IMMUTABLE_PATHS` environment overrides, so the cache policy is settable per deployment like every other web setting; `TI_WEB_STATIC_IMMUTABLE_PATHS` **replaces** the array, and an explicitly empty value means no long-lived paths
|
|
13
|
+
* refactor(web-server): the `/static` mounts write `Cache-Control` per file through `express.static`'s `setHeaders` rather than its `maxAge`/`immutable` options, since the policy is no longer uniform across the tree; the decision itself lives in the pure, unit-tested `TiWebServer.resolveStaticCachePolicy()` (config → policy + warnings, which the caller logs) and `TiWebServer.staticCacheControlFor()` (policy + file → header). The `staticCache` defaults deliberately live on the class rather than in `web-server.json`, because the constructor's `_.merge` merges arrays by index and a consumer's empty `immutablePaths` could otherwise never clear a default entry
|
|
14
|
+
* docs(web-framework): document the `staticCache` block, the revalidating default and the reasoning behind it, and the fingerprinting opt-in in the README
|
|
15
|
+
* build(release): bump package version from `1.18.1` to `1.19.0`
|
|
16
|
+
|
|
17
|
+
## Version 1.18.1
|
|
18
|
+
|
|
19
|
+
Enabling Azure SSO took the instance down at startup: the OAuth2 callback was registered by handing the configured callback value straight to Express as a route path, and the installation docs tell operators to set that value to the full absolute URL registered with the identity provider. Express 5 parses route patterns with path-to-regexp v8, where `:` opens a parameter name — so `https://host/login/azure-callback` throws `Missing parameter name at index 6` and the web server never starts. Google was affected identically, which also made this a prerequisite for the competence Cloud Run deployment, whose `deploy.sh` patches in an absolute callback URL (CA-97).
|
|
20
|
+
|
|
21
|
+
* fix(web-framework): register an OAuth2 callback by its **path** rather than by the configured value verbatim, so a callback given as the absolute URL registered with the provider no longer crashes startup; a callback that yields no usable path now logs a WARNING and skips that provider's endpoint instead of taking the instance down, matching how an enabled-but-unconfigured provider is already handled
|
|
22
|
+
* feat(web-framework): add `AuthManager.getOAuth2CallbackPath( authMethod )` and the pure, unit-tested `AuthManager.toCallbackPath( callbackUrl )` — reduces an absolute, protocol-relative, path or bare relative callback to its route path (query string and fragment stripped), or `null` when no usable path can be derived. The `redirect_uri` sent to the provider is deliberately left as configured, so an absolute callback keeps matching the provider registration exactly instead of depending on the forwarded protocol/host being correct
|
|
23
|
+
* docs(web-framework): document the OpenID Connect provider variables in the README, and state in the README, the competence `INSTALL.md` and `.env.example` that a callback URL may be given as either the absolute registered URL or a path — including what each implies for the `redirect_uri`, and that the path must be the one the app actually receives when a proxy strips a prefix
|
|
24
|
+
* build(release): bump package version from `1.18.0` to `1.18.1`
|
|
25
|
+
|
|
26
|
+
## Version 1.18.0
|
|
27
|
+
|
|
28
|
+
Every web-server setting a container deployment needs could be supplied per environment except one: the admin allowlist. `auth.admins` was readable only from the config file baked into the image, so a containerized deployment had no way to name an administrator — leaving the admin configuration screens unreachable, or forcing a real identity to be committed to the repository. This closes that gap in the existing `TI_WEB_*` override set (CA-94).
|
|
29
|
+
|
|
30
|
+
* feat(web-framework): add the `TI_WEB_AUTH_ADMINS` environment override — comma-separated, **replaces** `auth.admins` (matched against the session user's user ID, username or email), so the admin allowlist is configurable per environment like every other web setting; an explicitly empty value means no admins
|
|
31
|
+
* docs(web-framework): document `TI_WEB_AUTH_METHODS` and `TI_WEB_TRUSTED_ORIGINS` in the README's environment-variable list, which had never listed them
|
|
32
|
+
* build(release): bump package version from `1.17.1` to `1.18.0`
|
|
33
|
+
|
|
34
|
+
## Version 1.17.1
|
|
35
|
+
|
|
36
|
+
A validator that needs to compare its own config document against its previously committed state had no way to do so: `applyEdits`'s cross-document context resolves `getConfig` to the *pending* value for any document inside the current edit batch — by design, so a validator can check a sibling document's post-edit state — but a document is always part of its own edit batch, so calling `getConfig` on itself just hands back the same incoming value already passed as the validator's argument, never its prior state. This silently defeated the competence `research-consent` config's version-bump guard (CA-93).
|
|
37
|
+
|
|
38
|
+
* feat(web-framework): add `getStoredConfig(key)` to the `applyEdits` validator context (`ConfigService`) — always resolves the current *committed* value from the store, even for the document currently under validation, so a validator comparing its own document against its previous state has a way to do it; purely additive — `getConfig`'s existing cross-document (pending-value) semantics are unchanged
|
|
39
|
+
* build(release): bump package version from `1.17.0` to `1.17.1`
|
|
40
|
+
|
|
5
41
|
## Version 1.17.0
|
|
6
42
|
|
|
7
43
|
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
44
|
|
|
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
|
|
45
|
+
* 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. The method must be a **string** — a non-string is rejected outright rather than coerced, so a value whose `toString()` happens to yield a verb (`[ "get" ]`, `new String( "get" )`) cannot slip past the allowlist and register a route
|
|
10
46
|
* 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
47
|
* 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
48
|
* build(release): bump package version from `1.16.0` to `1.17.0`
|
package/README.md
CHANGED
|
@@ -17,6 +17,44 @@ The web server configuration (host, port, TLS, cookies, etc.) is normally provid
|
|
|
17
17
|
* `TI_WEB_USE_TLS` (`true`/`false`) toggles in-app TLS. Set `false` when a reverse proxy / ingress terminates TLS.
|
|
18
18
|
* `TI_WEB_TLS_CERT_PATH` / `TI_WEB_TLS_KEY_PATH` override the TLS certificate/key paths (only used when TLS is enabled).
|
|
19
19
|
* `TI_WEB_COOKIE_SECRET` sets the session cookie signing secret. Set a stable, private value for durable sessions and multi-replica deployments (otherwise a random per-process value is used).
|
|
20
|
+
* `TI_WEB_AUTH_METHODS` (comma-separated) **replaces** the enabled authentication methods (`auth.enabledMethods`), e.g. `openid-google` or `local,openid-google`.
|
|
21
|
+
* `TI_WEB_AUTH_ADMINS` (comma-separated) **replaces** the admin allowlist (`auth.admins`). Entries are matched against the session user's user ID, username or email, so an OpenID deployment lists emails. An explicitly empty value means *no admins*.
|
|
22
|
+
* `TI_WEB_TRUSTED_ORIGINS` (comma-separated) **replaces** the trusted request origins (`trustedOrigins`) — needed behind proxies that do not present the real external origin.
|
|
23
|
+
* `TI_WEB_STATIC_MAX_AGE` (whole seconds) overrides `staticCache.maxAge`. See [Static asset caching](#static-asset-caching).
|
|
24
|
+
* `TI_WEB_STATIC_IMMUTABLE` (`true`/`false`) overrides `staticCache.immutable`.
|
|
25
|
+
* `TI_WEB_STATIC_IMMUTABLE_PATHS` (comma-separated) **replaces** `staticCache.immutablePaths`. An explicitly empty value means *no long-lived paths*.
|
|
26
|
+
|
|
27
|
+
OpenID Connect providers are configured with their own variables — `TI_AZURE_AUTH_CLIENT_ID` / `TI_AZURE_AUTH_CLIENT_SECRET` / `TI_AZURE_AUTH_CALLBACK_URL` / `TI_AZURE_AUTH_DISCOVERY_URL`, and the `TI_GCLOUD_AUTH_*` equivalents. A callback URL may be given either as the full absolute URL registered with the provider (`https://your-host/login/azure-callback`) or as a path (`/login/azure-callback`): the server always listens on the path, while the `redirect_uri` sent to the provider is the absolute value verbatim if one was configured, and otherwise assembled from the request's forwarded protocol/host.
|
|
28
|
+
|
|
29
|
+
## Static asset caching
|
|
30
|
+
|
|
31
|
+
Everything under `/static` is served with a `Cache-Control` policy configured by the `staticCache` block:
|
|
32
|
+
|
|
33
|
+
```json
|
|
34
|
+
{
|
|
35
|
+
"staticCache": {
|
|
36
|
+
"maxAge": 0,
|
|
37
|
+
"immutable": false,
|
|
38
|
+
"immutablePaths": [ "/fonts/" ]
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
* `maxAge` — the `max-age` in **whole seconds** (not an express-style `"1y"` duration string; one is rejected with a warning rather than reinterpreted as milliseconds). `0`, the default, emits `public, max-age=0, must-revalidate`.
|
|
44
|
+
* `immutable` — adds the `immutable` directive. Defaults to `false`, and is **ignored with a warning when `maxAge` is 0**, since a response that is stale on arrival cannot also promise never to change.
|
|
45
|
+
* `immutablePaths` — path prefixes under `/static` (matched case-sensitively, on a path-segment boundary) served `public, max-age=31536000, immutable` regardless of the two settings above. Defaults to `[ "/fonts/" ]`. This **replaces** rather than merges, so `[]` means no long-lived paths.
|
|
46
|
+
|
|
47
|
+
**The default revalidates, and that is deliberate.** `immutable` tells a browser the bytes behind a URL will never change, and browsers honour it so completely that not even a manual reload revalidates. On a stable filename — which is what the framework's own assets use (`/static/scripts/ti-framework.js`, the theme sheets) — the promise is false, and a deployed CSS or JS fix simply never reaches anyone who has already visited, for up to a year, with no way to tell them otherwise. Revalidating costs a conditional request per asset, answered with a `304` from the `ETag`/`Last-Modified` that `express.static` still attaches — headers, no body.
|
|
48
|
+
|
|
49
|
+
**Opt back into `immutable` once your filenames are content-addressed.** If your build emits `app.a1b2c3.css`, or your application appends a content hash to each asset URL, the promise becomes true and there is real value in making it:
|
|
50
|
+
|
|
51
|
+
```json
|
|
52
|
+
{
|
|
53
|
+
"staticCache": { "maxAge": 31536000, "immutable": true }
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Fonts are the default exception because a released `.woff2` is an artifact rather than something edited in place, and its filename already carries the family, weight and style. If that is not how a given deployment manages its fonts, clear the list.
|
|
20
58
|
|
|
21
59
|
## Configure HTTPS for development
|
|
22
60
|
|
package/bin/web-server.js
CHANGED
|
@@ -39,6 +39,7 @@ const applyWebConfigEnvOverrides = require( "#web-config-env" );
|
|
|
39
39
|
* @property {number} port
|
|
40
40
|
* @property {string} publicPath
|
|
41
41
|
* @property {number} requestTimeout
|
|
42
|
+
* @property {SettingsStaticCache} staticCache
|
|
42
43
|
* @property {string} tlsCertPath
|
|
43
44
|
* @property {string} tlsKeyPath
|
|
44
45
|
* @property {boolean} useTLS
|
|
@@ -75,6 +76,13 @@ const applyWebConfigEnvOverrides = require( "#web-config-env" );
|
|
|
75
76
|
* @property {TiTokenEndpointAuthMethod} [tokenEndpointAuthMethod]
|
|
76
77
|
*/
|
|
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
|
+
|
|
78
86
|
/**
|
|
79
87
|
* @typedef {Object} SettingsCookies
|
|
80
88
|
* @property {string} secret
|
|
@@ -310,8 +318,20 @@ class TiWebServer extends ServiceConsumer {
|
|
|
310
318
|
this.#webServer.use( "/.well-known", express.static( path.join( this.#staticContentPaths[ 0 ], ".well-known" ), { dotfiles: "allow" } ) );
|
|
311
319
|
|
|
312
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 ) );
|
|
313
323
|
_.forEachRight( this.#staticContentPaths, ( staticContentPath ) => {
|
|
314
|
-
|
|
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
|
+
} ) );
|
|
315
335
|
} );
|
|
316
336
|
|
|
317
337
|
// Set up the web application routes:
|
|
@@ -516,12 +536,18 @@ class TiWebServer extends ServiceConsumer {
|
|
|
516
536
|
this.#webServer.post( "/logout", webHandlers.logoutHandler() );
|
|
517
537
|
this.#webServer.get( "/health", webHandlers.healthHandler() );
|
|
518
538
|
this.#webServer.get( "/me", webHandlers.userInformationHandler() );
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
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
|
+
} );
|
|
525
551
|
|
|
526
552
|
// Admin configuration-management API. Gated by the admin role; these paths are not in the unprotected-routes
|
|
527
553
|
// list, so they also inherit the server's global authentication + CSRF middleware.
|
|
@@ -621,9 +647,184 @@ class TiWebServer extends ServiceConsumer {
|
|
|
621
647
|
*/
|
|
622
648
|
static #REGISTRABLE_METHODS = new Set( [ "get", "post", "put", "patch", "delete", "options", "head", "all" ] );
|
|
623
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
|
+
|
|
624
822
|
/**
|
|
625
823
|
* Normalizes an HTTP method to a lower-case Express routing verb, or returns null if it is not a supported,
|
|
626
|
-
* registrable verb.
|
|
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.
|
|
627
828
|
*
|
|
628
829
|
* @method
|
|
629
830
|
* @static
|
|
@@ -632,7 +833,10 @@ class TiWebServer extends ServiceConsumer {
|
|
|
632
833
|
* @public
|
|
633
834
|
*/
|
|
634
835
|
static normalizeRegistrableMethod( method ) {
|
|
635
|
-
|
|
836
|
+
if ( typeof method !== "string" ) {
|
|
837
|
+
return null;
|
|
838
|
+
}
|
|
839
|
+
const verb = method.trim().toLowerCase();
|
|
636
840
|
return TiWebServer.#REGISTRABLE_METHODS.has( verb ) ? verb : null;
|
|
637
841
|
}
|
|
638
842
|
|
|
@@ -226,6 +226,51 @@ class AuthManager {
|
|
|
226
226
|
}
|
|
227
227
|
}
|
|
228
228
|
|
|
229
|
+
/**
|
|
230
|
+
* Used to get the local route path of the callback for the specified OAuth2 authentication method.
|
|
231
|
+
* <br/>
|
|
232
|
+
* A callback can legitimately be configured either as a path or as the full absolute URL registered with the
|
|
233
|
+
* identity provider. The absolute form is what the provider expects as the redirect URI, but it is not a usable
|
|
234
|
+
* Express route pattern, so this reduces whatever is configured to the path the server must actually listen on.
|
|
235
|
+
*
|
|
236
|
+
* @method
|
|
237
|
+
* @param {TiAuthMethod} authMethod
|
|
238
|
+
* @returns {string|null} The route path, or null if the configured callback yields no usable path.
|
|
239
|
+
* @throws {TiException.E_SEC_UNRECOGNIZED_AUTH_METHOD} If the requested OAuth2 method is not recognized or enabled.
|
|
240
|
+
* @public
|
|
241
|
+
*/
|
|
242
|
+
getOAuth2CallbackPath( authMethod ) {
|
|
243
|
+
return AuthManager.toCallbackPath( this.getOAuth2CallbackUrl( authMethod ) );
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Reduces a configured OAuth2 callback value to the local route path it corresponds to. Accepts an absolute URL
|
|
248
|
+
* ('https://host/login/azure-callback'), a protocol-relative URL, or a path with or without its leading slash,
|
|
249
|
+
* and strips any query string or fragment. Pure and static; exposed for unit testing.
|
|
250
|
+
* <br/>
|
|
251
|
+
* NOTE: This exists because Express 5 parses a route pattern with path-to-regexp v8, where ':' opens a parameter
|
|
252
|
+
* name — so an absolute URL used verbatim as a route path throws 'Missing parameter name' at startup.
|
|
253
|
+
*
|
|
254
|
+
* @method
|
|
255
|
+
* @static
|
|
256
|
+
* @param {string} callbackUrl
|
|
257
|
+
* @returns {string|null} The route path, or null if no usable path can be derived.
|
|
258
|
+
* @public
|
|
259
|
+
*/
|
|
260
|
+
static toCallbackPath( callbackUrl ) {
|
|
261
|
+
const value = String( callbackUrl || "" ).trim();
|
|
262
|
+
if ( value === "" ) {
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
try {
|
|
266
|
+
// The base is only a parsing anchor — an absolute or protocol-relative value overrides it, while a
|
|
267
|
+
// path or bare relative value resolves against it. Either way only the pathname is used.
|
|
268
|
+
return new URL( value, "http://localhost" ).pathname;
|
|
269
|
+
} catch {
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
229
274
|
/* Private interface */
|
|
230
275
|
|
|
231
276
|
/**
|
|
@@ -41,9 +41,23 @@ const instancePathToDataPath = ( instancePath ) => {
|
|
|
41
41
|
*/
|
|
42
42
|
|
|
43
43
|
/**
|
|
44
|
-
* @
|
|
45
|
-
*
|
|
46
|
-
*
|
|
44
|
+
* The cross-document read context passed to every {@link SemanticValidator}, built fresh for each
|
|
45
|
+
* {@link ConfigService#applyEdits} call.
|
|
46
|
+
*
|
|
47
|
+
* @typedef {Object} ValidatorContext
|
|
48
|
+
* @property {function(string): Promise<*>} getConfig Resolves the *pending* value of `key` when it is part of the
|
|
49
|
+
* current edit batch, otherwise its current committed value. Lets a validator check a sibling document's
|
|
50
|
+
* post-edit state — but calling this for the document being validated itself just returns the same incoming
|
|
51
|
+
* value already passed as the validator's first argument, not its prior state.
|
|
52
|
+
* @property {function(string): Promise<*>} getStoredConfig Always resolves the current committed value of `key`,
|
|
53
|
+
* even when `key` is the document currently under validation. Use this to compare a document against its own
|
|
54
|
+
* previous state (e.g. detecting an edit that should have bumped a version marker).
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* @typedef {function(Object, ValidatorContext): (ConfigValidationIssue[]|Promise<ConfigValidationIssue[]>)} SemanticValidator
|
|
59
|
+
* A semantic validator receives the candidate value and a {@link ValidatorContext} and returns the issues it found
|
|
60
|
+
* (empty array = OK). May be async.
|
|
47
61
|
*/
|
|
48
62
|
|
|
49
63
|
/**
|
|
@@ -163,7 +177,7 @@ class ConfigRegistry {
|
|
|
163
177
|
* @method
|
|
164
178
|
* @param {string} configKey
|
|
165
179
|
* @param {Object} value
|
|
166
|
-
* @param {
|
|
180
|
+
* @param {ValidatorContext} [context] Passed to each semantic validator.
|
|
167
181
|
* @returns {Promise<{valid: boolean, errors: ConfigValidationIssue[]}>}
|
|
168
182
|
* @throws {TiException.E_WEB_INVALID_REQUEST_PARAMETERS} If the document is not registered.
|
|
169
183
|
* @public
|
|
@@ -13,9 +13,11 @@ const exceptions = require( "@ti-engine/core/exceptions" );
|
|
|
13
13
|
*
|
|
14
14
|
* Two layers:
|
|
15
15
|
* - **Document level** — {@link ConfigService#applyEdits}: validate every affected document (schema + semantic,
|
|
16
|
-
* with a cross-document
|
|
17
|
-
*
|
|
18
|
-
*
|
|
16
|
+
* with a cross-document {@link ValidatorContext} whose `getConfig` sees the *pending* values of the same edit —
|
|
17
|
+
* letting a validator check a sibling document's post-edit state — while `getStoredConfig` always returns the
|
|
18
|
+
* committed value, even for the document currently under validation) and, only if all pass, commit them as one
|
|
19
|
+
* change-set. Validation failures return `{ ok:false, errors }` and write nothing; a version conflict from the
|
|
20
|
+
* store surfaces as a rejection.
|
|
19
21
|
* - **Entity level** — composite editors registered with `compose(docs)→view` / `decompose(edited, docs)→{key:value}`,
|
|
20
22
|
* so the UI edits a domain entity (e.g. a "competency") that is projected from, and scattered back into, several
|
|
21
23
|
* documents. {@link ConfigService#saveEditorEdit} decomposes the edit and routes it through `applyEdits`.
|
|
@@ -61,8 +63,13 @@ class ConfigService {
|
|
|
61
63
|
return Promise.reject( exceptions.raise( exceptions.exceptionCode.E_WEB_INVALID_REQUEST_PARAMETERS, { reason: "invalid-apply-input" } ) );
|
|
62
64
|
}
|
|
63
65
|
|
|
64
|
-
// Cross-document validation context: a document being edited is seen at its *pending* value
|
|
65
|
-
//
|
|
66
|
+
// Cross-document validation context: a document being edited is seen at its *pending* value via getConfig —
|
|
67
|
+
// even when that document is the one currently under validation, so calling getConfig on "yourself" just
|
|
68
|
+
// hands back the same incoming value already passed as the validator's first argument, not its prior state.
|
|
69
|
+
// This lets a validator on one document check against the post-edit state of its siblings. getStoredConfig
|
|
70
|
+
// is the counterpart: it always resolves the committed value, so a validator that must compare its own
|
|
71
|
+
// document against its previous state (e.g. detecting an edit that should have bumped a version marker)
|
|
72
|
+
// uses that instead.
|
|
66
73
|
const pending = {};
|
|
67
74
|
for ( const edit of edits ) {
|
|
68
75
|
pending[ edit.configKey ] = edit.value;
|
|
@@ -73,7 +80,11 @@ class ConfigService {
|
|
|
73
80
|
return Promise.resolve( clone( pending[ key ] ) );
|
|
74
81
|
}
|
|
75
82
|
return this.#store.getCurrent( key ).then( ( current ) => ( current ? current.value : null ) );
|
|
76
|
-
}
|
|
83
|
+
},
|
|
84
|
+
// Always the committed value, even for a document inside this edit batch. A validator comparing its own
|
|
85
|
+
// document against its previous state must use this; getConfig would hand back the pending value it is
|
|
86
|
+
// currently validating.
|
|
87
|
+
getStoredConfig: ( key ) => this.#store.getCurrent( key ).then( ( current ) => ( current ? current.value : null ) )
|
|
77
88
|
};
|
|
78
89
|
|
|
79
90
|
return Promise.all( edits.map( ( edit ) => {
|
|
@@ -15,8 +15,8 @@ const tools = require( "@ti-engine/core/tools" );
|
|
|
15
15
|
* Each override is applied ONLY when its environment variable is defined, so an absent variable leaves the
|
|
16
16
|
* configured/default value untouched (fully backward compatible). This gives ti-engine web servers 12-factor,
|
|
17
17
|
* container-friendly control over network binding, TLS, the session cookie secret, the enabled authentication
|
|
18
|
-
* methods,
|
|
19
|
-
* `TI_WEB_TRUSTED_ORIGINS` fully REPLACE their config arrays (`auth.enabledMethods` / `trustedOrigins`) rather than
|
|
18
|
+
* methods, the admin allowlist, the trusted request origins, and the `/static` cache policy without editing config files. Note `TI_WEB_AUTH_METHODS`,
|
|
19
|
+
* `TI_WEB_AUTH_ADMINS`, `TI_WEB_TRUSTED_ORIGINS`, and `TI_WEB_STATIC_IMMUTABLE_PATHS` fully REPLACE their config arrays (`auth.enabledMethods` / `auth.admins` / `trustedOrigins` / `staticCache.immutablePaths`) rather than
|
|
20
20
|
* merging — the config-file merge is by-index and cannot cleanly override an array.
|
|
21
21
|
*
|
|
22
22
|
* @method
|
|
@@ -55,9 +55,30 @@ function applyWebConfigEnvOverrides( config, env = process.env ) {
|
|
|
55
55
|
config.auth = config.auth || {};
|
|
56
56
|
config.auth.enabledMethods = env.TI_WEB_AUTH_METHODS.split( "," ).map( ( method ) => method.trim() ).filter( ( method ) => method.length > 0 );
|
|
57
57
|
}
|
|
58
|
+
if ( env.TI_WEB_AUTH_ADMINS !== undefined ) {
|
|
59
|
+
config.auth = config.auth || {};
|
|
60
|
+
config.auth.admins = env.TI_WEB_AUTH_ADMINS.split( "," ).map( ( entry ) => entry.trim() ).filter( ( entry ) => entry.length > 0 );
|
|
61
|
+
}
|
|
58
62
|
if ( env.TI_WEB_TRUSTED_ORIGINS !== undefined ) {
|
|
59
63
|
config.trustedOrigins = env.TI_WEB_TRUSTED_ORIGINS.split( "," ).map( ( origin ) => origin.trim() ).filter( ( origin ) => origin.length > 0 );
|
|
60
64
|
}
|
|
65
|
+
if ( env.TI_WEB_STATIC_MAX_AGE !== undefined ) {
|
|
66
|
+
// Seconds, matching the `Cache-Control` directive itself. A non-integer is left to the config value rather
|
|
67
|
+
// than coerced, exactly as TI_WEB_PORT is — `TiWebServer.resolveStaticCachePolicy` reports the bad value.
|
|
68
|
+
const maxAge = Number( env.TI_WEB_STATIC_MAX_AGE );
|
|
69
|
+
if ( Number.isInteger( maxAge ) && maxAge >= 0 ) {
|
|
70
|
+
config.staticCache = config.staticCache || {};
|
|
71
|
+
config.staticCache.maxAge = maxAge;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if ( env.TI_WEB_STATIC_IMMUTABLE !== undefined ) {
|
|
75
|
+
config.staticCache = config.staticCache || {};
|
|
76
|
+
config.staticCache.immutable = tools.toBool( env.TI_WEB_STATIC_IMMUTABLE );
|
|
77
|
+
}
|
|
78
|
+
if ( env.TI_WEB_STATIC_IMMUTABLE_PATHS !== undefined ) {
|
|
79
|
+
config.staticCache = config.staticCache || {};
|
|
80
|
+
config.staticCache.immutablePaths = env.TI_WEB_STATIC_IMMUTABLE_PATHS.split( "," ).map( ( prefix ) => prefix.trim() ).filter( ( prefix ) => prefix.length > 0 );
|
|
81
|
+
}
|
|
61
82
|
return config;
|
|
62
83
|
}
|
|
63
84
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ti-engine/web-framework",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.19.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",
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"cookie-parser": "^1.4.7",
|
|
34
34
|
"express": "^5.2.1",
|
|
35
35
|
"express-session": "^1.19.0",
|
|
36
|
-
"helmet": "^8.
|
|
36
|
+
"helmet": "^8.3.0",
|
|
37
37
|
"lodash": "^4.18.1",
|
|
38
38
|
"openid-client": "^6.8.4"
|
|
39
39
|
},
|