@ti-engine/web-framework
v1.41.0
Published
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
Maintainers
Readme
ti-engine web framework
Flexible framework for the creation of microservices with node.js.
Information
This is a customizable web framework based on the ti-engine framework. Currently under development.
Environment variables
The web server configuration (host, port, TLS, cookies, etc.) is normally provided via the service configuration file merged in the TiWebServer constructor. The following environment variables can override individual values at runtime — useful for container/12-factor deployments where the same image is configured per environment:
TI_WEB_HOSToverrides the bind address (e.g.0.0.0.0in a container). Defaults to the value in the web server config.TI_WEB_PORToverrides the listen port.TI_WEB_USE_TLS(true/false) toggles in-app TLS. Setfalsewhen a reverse proxy / ingress terminates TLS.TI_WEB_TLS_CERT_PATH/TI_WEB_TLS_KEY_PATHoverride the TLS certificate/key paths (only used when TLS is enabled).TI_WEB_COOKIE_SECRETsets 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).TI_WEB_TLS_CERT_PATHis also read by the container liveness probe (below), which verifies against that certificate.TI_WEB_SESSION_IDLE_TIMEOUT(whole minutes) sets how long a signed-in session survives without activity, overridingcookies.maxAge. The window is rolling: every response re-stamps the cookie, so a session ends only after that long with no request at all. Note that a user typing into a form makes no requests, so set this comfortably longer than the longest form a user fills in one sitting. Defaults to 480 (eight hours).TI_WEB_AUTH_METHODS(comma-separated) replaces the enabled authentication methods (auth.enabledMethods), e.g.openid-googleorlocal,openid-google.TI_WEB_AUTH_LOCAL_USERS_PATHoverrides the local user directory's file path (auth.local.usersPath), which backslocalsign-in. An explicitly empty value means no directory, so every local sign-in is refused. See Local (username/password) authentication.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.TI_WEB_TRUSTED_ORIGINS(comma-separated) replaces the trusted request origins (trustedOrigins) — needed behind proxies that do not present the real external origin.TI_WEB_STATIC_MAX_AGE(whole seconds) overridesstaticCache.maxAge. See Static asset caching.TI_WEB_STATIC_IMMUTABLE(true/false) overridesstaticCache.immutable.TI_WEB_STATIC_IMMUTABLE_PATHS(comma-separated) replacesstaticCache.immutablePaths. An explicitly empty value means no long-lived paths.TI_WEB_SERVER_TIMING(true/false) overridesserverTiming: whether every response says, in aServer-Timingheader, where its time went in the process. Off by default. See Timing a response.
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.
Authentication and authorization
TiWebServer#augmentSession is the hook through which an application derives its own session roles — from an identity store, the org chart, or wherever a deployment keeps that mapping — once per login, before the framework's own additive admin role (auth.admins, see Environment variables) is applied on top; the default is a no-op that returns the session unchanged. Throwing from the hook refuses the sign-in rather than admitting a session the application could not map to a principal: the framework destroys the freshly regenerated session so nothing usable survives the refusal, the login handler responds 401, and the error handler sends the browser back to the login page with the exception code in the ?error= query parameter — the same path a failed OpenID callback takes, regardless of which auth method was used.
The identity an OpenID sign-in puts on the session
A sign-in is resolved from the validated ID token claims and the userinfo response together, not from userinfo alone. Within a single claim userinfo wins — it is the fresher of the two, and openid-client has already verified that both describe the same subject — and the ID token fills the gaps.
| Session field | Resolved from, in order |
|---------------|----------------------------------------------------------------------------------|
| userID | oauth2: + the subject (sub) |
| username | preferred_username, then upn, then the e-mail, then name, then sub:<sub> |
| email | the email claim from either source — and nothing else |
| name | the name claim from either source |
Both sources are needed because of what the Microsoft identity platform returns. Its userinfo endpoint answers with sub, name, family_name, given_name, picture and — only when the optional claim is configured — an email taken from the directory's mail attribute. It never returns preferred_username: on Entra that claim lives in the ID token, and it holds the UPN. The UPN is the address an operator actually knows and lists in auth.admins, so reading userinfo alone dropped the one identifier such a deployment is configured around, and an allowlisted administrator had nothing on their session for isAdminIdentity to match.
username is the one field choosing between two different claims, and there the claim outranks the source: preferred_username beats upn whichever response carried it. preferred_username is the standard OIDC claim for a human-readable identifier and upn a Microsoft extension, and "fresher" earns nothing between two stable identifiers that do not differ across the two responses. No ordering can rescue a deployment whose allowlist names the claim that lost, since only one string can be the username — what covers that is the allowlist matching user ID, username or e-mail, and a consumer naming all three when it refuses a sign-in.
Each resolved value also carries its provenance (claims.preferred_username, userinfo.email, …), so a deployment can report how its provider was understood without writing anybody's identifiers to a log — which matters because auditing.logMinLevel ships at 0 with console logging on.
The UPN is offered as the username, never as the email. It is e-mail-shaped and usually routable, but it is a sign-in name rather than a mailbox, and email is what a consuming application resolves its own directory by — widening that would change which principal an identity maps to. The admin allowlist matches user ID, username or e-mail, so listing a UPN there works either way.
An address the provider reports as unverified (email_verified: false, in either source) is refused. An absent claim is not a rejection: Google emits the claim, Entra does not emit it at all, and treating "absent" as "unverified" would refuse every sign-in on an Azure deployment.
Local (username/password) authentication
local is one of the configurable auth.enabledMethods sign-in methods (see Environment variables, TI_WEB_AUTH_METHODS). It is backed by a JSON file of user records — there is no built-in account of any kind.
The users file
auth.local.usersPath (override: TI_WEB_AUTH_LOCAL_USERS_PATH) points at a JSON file holding an array of records:
[
{
"username": "jdoe",
"email": "[email protected]",
"name": "Jane Doe",
"passwordHash": "scrypt$16384$8$1$<salt-base64>$<hash-base64>"
}
]username,email,nameandpasswordHashare required.emailis required because a consuming application resolves the signed-in identity by it, the same way it would for an OpenID identity — a record with no email cannot reach an application at all.userIDis optional. When omitted, one is derived from the username, so it stays stable across restarts and logins; supply it explicitly only when something else needs to match a specific value (e.g.auth.admins).disabled: truekeeps the record (and its username) in the file while refusing every sign-in for it.
Generate passwordHash with the bundled CLI. It reads the password from stdin, never an argument, so it never lands in shell history or a process listing (ps), and it never echoes the password back:
npm run hash-password -w @ti-engine/web-frameworkType or pipe the password, then EOF; only the resulting hash is written to stdout.
That npm run form only works inside this monorepo (it is a workspace script). A consumer of the published @ti-engine/web-framework package has no bin entry to run it by name — the script ships under bin/build/ regardless, so invoke it by its path inside node_modules instead:
node ./node_modules/@ti-engine/web-framework/bin/build/hash-password.jsThe file is the source of truth
On every boot, the file is read and reconciled into the running directory: an added record starts working, a changed passwordHash takes effect, and — this is the point — a record removed from the file is removed from the directory, revoking that user's access on the next restart. Editing the file and restarting is the whole revocation mechanism; there is no separate delete action.
Every failure refuses rather than admits
local enabled with no auth.local.usersPath configured, a file that cannot be read, a file that is not valid JSON, or a file that yields zero valid records after validation — each of these logs a startup WARNING and refuses every local sign-in, rather than admitting one or falling back to a default. A failed read deliberately does not reconcile, so a temporarily broken volume mount leaves previously stored records untouched instead of wiping them; those records stay inert (unused) while the load keeps failing, because sign-ins are refused anyway.
There is no rate limiting, no lockout after repeated failures, and no password policy. Treat local on an internet-facing deployment as a deliberate risk until those exist.
The login screen
The login screen renders before sign-in, so none of an application's own fragments are in play. It has two slots an application fills by shipping a file of the same relative path in its own static directory; the static-path search is reverse-order, so the application's copy wins:
| Slot | Where | Framework default |
|---|---|---|
| fragments/components/component-login-brand.html | above the sign-in card | the framework's mark, "Welcome back" and "Sign in to continue" |
| fragments/components/component-login-extra.html | below the sign-in card | empty |
Use the brand slot to name the application and say what it is for. Use the extra slot for anything a first-time visitor should read after the sign-in controls. Neither slot can change the sign-in controls themselves; those stay the framework's, gated to the enabled methods.
Every string on the screen goes through x-text-label under interface.default.login.*: welcome, sign-in-prompt, username, username-placeholder, password, password-placeholder, sign-in, or-continue-with, sign-in-google, sign-in-azure, no-sign-in-method and error-sign-in-failed. An application normally points TI_LOCALIZATION_LABELS_PATH at its own catalogue alone, so the framework's translations never reach it. It adds the keys it wants translated to its own catalogue, and a key it leaves out renders the English text written in the fragment.
Static asset caching
Everything under /static is served with a Cache-Control policy configured by the staticCache block:
{
"staticCache": {
"maxAge": 0,
"immutable": false,
"immutablePaths": [ "/fonts/" ]
}
}maxAge— themax-agein whole seconds (not an express-style"1y"duration string; one is rejected with a warning rather than reinterpreted as milliseconds).0, the default, emitspublic, max-age=0, must-revalidate.immutable— adds theimmutabledirective. Defaults tofalse, and is ignored with a warning whenmaxAgeis 0, since a response that is stale on arrival cannot also promise never to change.immutablePaths— path prefixes under/static(matched case-sensitively, on a path-segment boundary) servedpublic, max-age=31536000, immutableregardless of the two settings above. Defaults to[ "/fonts/" ]. This replaces rather than merges, so[]means no long-lived paths.
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.
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:
{
"staticCache": { "maxAge": 31536000, "immutable": true }
}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.
Since 1.39.0 the framework content-addresses its own references. Every src or href pointing at /static/… in a fragment the framework serves — the shell included — is written with ?v=<content hash> of the file that would be served for it (an application's override, where one exists). A request whose v matches the file's current bytes is answered public, max-age=31536000, immutable whatever staticCache says; a bare URL, or one with a stale hash, gets the policy above. A reference the application writes into its own fragments is fingerprinted the same way; one built at run time in JavaScript is not, and keeps revalidating.
What the browser downloads
Since 1.39.0:
- Responses are compressed (brotli, else gzip), except a view that embeds a CSRF token.
- The label catalogue is not in
/app/config. The configuration carrieslabelsBundle: { hash, url }, andGET /app/labels/<hash>serves the catalogueimmutable— the browser downloads it once per release and language. OverridegetClientLabels( language )in yourTiWebAppManagerto leave out what the browser never reads; the result must not change for the life of the process, since it is hashed once. - Screen fragments requested by HTMX revalidate (
private, no-cache), so a repeat visit to a screen is a304. Full pages stayno-store. /staticis served before the session middleware, so an asset never reads or writes a session.
Screens that never change
A screen fragment is served at an address that names the screen, not a version of it, so by default it revalidates:
a repeat visit costs a request answered 304. That saves the bytes, but not the round trip. On a hosted deployment
the round trip is the whole cost: a user guide chapter took 4–7 ms in the process and 0.6–0.9 s in the browser.
A fragment whose markup is the same for every viewer until the next deployment can be declared immutable (since
1.41.0):
this.addFragment( "help-overview", { title: "User Guide", path: "fragments/frame-help-overview.html", immutable: true } );How it behaves:
- Every reference carries an address. Every
hx-get="/app/help-overview"in every fragment the framework serves, the shell included, is writtenhx-get="/app/help-overview?v=<version>". Ahx-push-url="true"(orhx-replace-url) on the same element becomeshx-push-url="/app/help-overview", so the address bar shows the plain path. - A request for the current version is kept for good. It is answered
private, max-age=31536000, immutable, and a revisit makes no request at all. A stale or missingvgets the normal revalidating answer. - One version covers every immutable fragment together. Chapters link to one another, so any change to any of them moves every address. A deployment that changes none of them keeps every cached copy.
The declaration is a promise the framework also checks:
- Each response is compared with the markup its address was computed from. A fragment whose output depends on the
request (the nonce placeholder, a CSRF token, anything
transformHtmladds per request) never matches. It is served revalidating, and a warning is logged once. - A fragment with
rolescannot beimmutable.addFragmentthrows. The browser keeps the copy, not the session, so it would be served to whoever next uses that browser, without a role check. - Nothing is addressed while
TI_WEB_APP_STATIC_CACHE_DISABLED=true, a development setting. A file edited under a running process would change its bytes without changing its address.
Only declare content that anyone signed in may see and that carries nothing about the viewer, such as help,
documentation or a static reference page. A screen whose data arrives by a separate request gains nothing: its 304
is already cheap in bytes, and its template changes with releases just the same.
Timing a response
With serverTiming: true (or TI_WEB_SERVER_TIMING=true), every response carries a Server-Timing header (since
1.41.0), which the browser's developer tools show under Timing:
Server-Timing: app;dur=4.1;desc="Frankfurt am Main (WEUR, DE)", session;dur=2.4app: from the request reaching the process to its headers being written.session: how long reading the session took, which is the state-store round trip. It is absent for/static, which is served before the session.
A header already set, for example by a proxy that adds its own metrics, is appended to. Override
describeInstance() in your TiWebServer subclass to give app a description, such as where a container platform
placed the instance. It is read once, at start, reduced to printable ASCII, and quoted.
It is off by default: response timings are a small disclosure of how the server works, and a deployment chooses to make it.
Configure HTTPS for development
Use the mkcert tool to create a certificate for development.
Step 1: Install the tool:
choco install mkcertStep 2: Install certificate authority:
mkcert -installStep 3: Generate certificate files for localhost:
mkcert localhost 127.0.0.1 ::1License
Apache-2.0 © Boris Kostadinov. See LICENSE.
Container liveness probe
bin/healthcheck.js asks a running server whether it is still serving and exits 0 only if it says yes. Point a
Dockerfile HEALTHCHECK at it:
HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \
CMD ["node", "/app/node_modules/@ti-engine/web-framework/bin/healthcheck.js"]It reads TI_WEB_USE_TLS through the same tools.toBool the server uses, so the probe's transport cannot drift
from the server's, and calls /health — the unprotected route webHandlers.healthHandler serves. Writing this
inline in a Dockerfile is the mistake it exists to prevent: an http:// URL hardcoded there reports a TLS-enabled
container unhealthy forever, and Docker restarts a server that is answering correctly.
With TLS on it verifies the certificate rather than skipping verification, anchoring trust to the server's own
certificate at TI_WEB_TLS_CERT_PATH and taking the name to check from that certificate — so a certificate issued
for a public hostname still passes while the probe connects to 127.0.0.1. Set that variable when you terminate
TLS inside the container. Without it there is nothing to anchor to and the probe falls back to establishing that
the port accepts connections, which is weaker but neither disables verification nor restarts a healthy container.
