@ti-engine/web-framework 1.25.1 → 1.27.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 +40 -0
- package/README.md +1 -0
- package/bin/web-server.js +9 -0
- package/bin/web-server.json +1 -1
- package/components/config-registry.js +33 -7
- package/components/config-service.js +32 -0
- package/components/web-config-env.js +11 -0
- package/package.json +1 -1
- package/types/components/config-registry.d.ts +20 -0
- package/types/components/config-service.d.ts +25 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,46 @@
|
|
|
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.27.0
|
|
6
|
+
|
|
7
|
+
Session lifetime. Two defects that together threw a signed-in user out roughly ten minutes after sign-in, however
|
|
8
|
+
hard they were working.
|
|
9
|
+
|
|
10
|
+
* fix(web-server): `cookies.maxAge` was `604800` — seven days expressed in **seconds**, written into a field
|
|
11
|
+
express-session reads as **milliseconds** (`set maxAge(ms)`). Every session therefore lasted 604.8 seconds. The
|
|
12
|
+
value is now `28800000`: eight hours, in the unit the field actually takes.
|
|
13
|
+
* fix(web-server): enable `rolling: true`, making the window slide with use. express-session re-sends the cookie
|
|
14
|
+
only when the session is new, when `rolling` is on, or when the session data itself changed
|
|
15
|
+
(`shouldSetCookie`) — and nothing changes it after sign-in, since `augmentSession` runs once inside
|
|
16
|
+
`regenerateAndSaveSession` and the CSRF handler writes its token only when absent. The limit was therefore
|
|
17
|
+
absolute from sign-in rather than an idle timeout. The store side was never the problem: `SessionStore.touch`
|
|
18
|
+
slid the Redis TTL correctly the whole time, which is precisely why the fault was invisible from the server —
|
|
19
|
+
the browser was dropping an expired cookie the server still considered live. `resave: false` is unchanged, so
|
|
20
|
+
this adds no store writes.
|
|
21
|
+
* feat(config): `TI_WEB_SESSION_IDLE_TIMEOUT` overrides the idle window, in whole **minutes**. The unit is named in
|
|
22
|
+
the variable and converted internally, so the confusion above is not expressible through it. A non-integer or
|
|
23
|
+
non-positive value is ignored, leaving the config value standing — the same posture as `TI_WEB_STATIC_MAX_AGE`.
|
|
24
|
+
|
|
25
|
+
Note for consumers choosing a value: a rolling window is refreshed by requests, and a browser filling in a form
|
|
26
|
+
makes none. Set it longer than the longest uninterrupted form-filling sitting your application expects, or that
|
|
27
|
+
user loses unsaved work when they finally submit.
|
|
28
|
+
|
|
29
|
+
## Version 1.26.0
|
|
30
|
+
|
|
31
|
+
* feat(config-management): report stored documents that no longer satisfy their registered schema.
|
|
32
|
+
`ConfigService.listSchemaViolations()` returns one entry per registered document whose **stored** value fails its
|
|
33
|
+
schema, and `ConfigRegistry.validateSchema( configKey, value )` exposes the schema half of validation on its own.
|
|
34
|
+
Nothing validated a stored value on the way out before this: the store hands back whatever it holds and a consumer
|
|
35
|
+
freezes it into its config exports, so a release that tightens a schema — adding a required top-level key, say —
|
|
36
|
+
leaves any deployment seeded before the change serving a document that can no longer be saved. The failure used to
|
|
37
|
+
surface much later, as an admin edit rejected for a key the admin never touched, in a screen unrelated to the
|
|
38
|
+
change. Schema-only by design: a semantic validator may need a `ValidatorContext` the caller has no reason to
|
|
39
|
+
build, and several are about an *edit* rather than the document standing alone. Read-only by design too —
|
|
40
|
+
reconciling a stale document is the drift panel's job, under audit; an automatic repair here would write config
|
|
41
|
+
outside the change-set machinery. A document that has never been stored is not a violation.
|
|
42
|
+
* refactor(config-management): `ConfigRegistry.validate()` now delegates its schema step to `validateSchema()`, so
|
|
43
|
+
the two can never disagree about what is structurally admissible.
|
|
44
|
+
|
|
5
45
|
## Version 1.25.1
|
|
6
46
|
|
|
7
47
|
* fix(ti-framework): resolve a label key whose own name contains a literal dot. `getLabel` split the key on every
|
package/README.md
CHANGED
|
@@ -17,6 +17,7 @@ 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_SESSION_IDLE_TIMEOUT` (whole minutes) sets how long a signed-in session survives **without activity**, overriding `cookies.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).
|
|
20
21
|
* `TI_WEB_AUTH_METHODS` (comma-separated) **replaces** the enabled authentication methods (`auth.enabledMethods`), e.g. `openid-google` or `local,openid-google`.
|
|
21
22
|
* `TI_WEB_AUTH_LOCAL_USERS_PATH` overrides the local user directory's file path (`auth.local.usersPath`), which backs `local` sign-in. An explicitly empty value means *no directory*, so every local sign-in is refused. See [Local (username/password) authentication](#local-usernamepassword-authentication).
|
|
22
23
|
* `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*.
|
package/bin/web-server.js
CHANGED
|
@@ -319,6 +319,15 @@ class TiWebServer extends ServiceConsumer {
|
|
|
319
319
|
secret: this.serviceConfig.cookies.secret || randomBytes( 32 ).toString( "base64" ),
|
|
320
320
|
resave: false,
|
|
321
321
|
saveUninitialized: false,
|
|
322
|
+
// Slide the window with use, rather than expiring a fixed interval after sign-in. Without this,
|
|
323
|
+
// express-session re-sends the cookie only when the session data itself changes
|
|
324
|
+
// (`shouldSetCookie`), and nothing changes it after login — `augmentSession` runs once inside
|
|
325
|
+
// `regenerateAndSaveSession`, and the CSRF handler writes its token only when absent. The cookie
|
|
326
|
+
// was therefore stamped at sign-in and never refreshed: an absolute limit that expired people
|
|
327
|
+
// mid-task however hard they were working. `resave: false` keeps the store write-free; the
|
|
328
|
+
// session's TTL there is kept alive by `SessionStore.touch`, which express-session calls per
|
|
329
|
+
// request.
|
|
330
|
+
rolling: true,
|
|
322
331
|
cookie: {
|
|
323
332
|
path: this.serviceConfig.cookies.path,
|
|
324
333
|
httpOnly: this.serviceConfig.cookies.httpOnly,
|
package/bin/web-server.json
CHANGED
|
@@ -197,13 +197,7 @@ class ConfigRegistry {
|
|
|
197
197
|
return Promise.reject( exceptions.raise( exceptions.exceptionCode.E_WEB_INVALID_REQUEST_PARAMETERS, { reason: "unknown-config", configKey: configKey } ) );
|
|
198
198
|
}
|
|
199
199
|
|
|
200
|
-
const errors =
|
|
201
|
-
const validateSchema = this.#schemaValidator( registration );
|
|
202
|
-
if ( !validateSchema( value ) ) {
|
|
203
|
-
for ( const error of ( validateSchema.errors || [] ) ) {
|
|
204
|
-
errors.push( { path: error.dataPath || instancePathToDataPath( error.instancePath ), message: error.message || "schema violation", code: "schema", params: error.params } );
|
|
205
|
-
}
|
|
206
|
-
}
|
|
200
|
+
const errors = this.validateSchema( configKey, value ).errors.slice();
|
|
207
201
|
|
|
208
202
|
return Promise.all( registration.validators.map( ( validator ) => {
|
|
209
203
|
return Promise.resolve( validator( value, context ) ).then( ( issues ) => ( Array.isArray( issues ) ? issues : [] ) );
|
|
@@ -217,6 +211,38 @@ class ConfigRegistry {
|
|
|
217
211
|
} );
|
|
218
212
|
}
|
|
219
213
|
|
|
214
|
+
/**
|
|
215
|
+
* Validates a value against a document's registered schema **only**, skipping the semantic validators.
|
|
216
|
+
* <br/>
|
|
217
|
+
* Separate from {@link ConfigRegistry#validate} because the two answer different questions. A semantic validator
|
|
218
|
+
* may need a {@link ValidatorContext} the caller has no reason to build, and several of them are about an edit
|
|
219
|
+
* (did the version move? does this removal orphan a reference?) rather than about the document standing alone.
|
|
220
|
+
* A caller that just wants to know whether a value is structurally admissible — a boot-time check on what the
|
|
221
|
+
* store already holds, say — needs this half and not the other.
|
|
222
|
+
*
|
|
223
|
+
* @method
|
|
224
|
+
* @param {string} configKey
|
|
225
|
+
* @param {Object} value
|
|
226
|
+
* @returns {{valid: boolean, errors: ConfigValidationIssue[]}}
|
|
227
|
+
* @throws {TiException.E_WEB_INVALID_REQUEST_PARAMETERS} If the document is not registered.
|
|
228
|
+
* @public
|
|
229
|
+
*/
|
|
230
|
+
validateSchema( configKey, value ) {
|
|
231
|
+
const registration = this.#registrations.get( configKey );
|
|
232
|
+
if ( !registration ) {
|
|
233
|
+
throw exceptions.raise( exceptions.exceptionCode.E_WEB_INVALID_REQUEST_PARAMETERS, { reason: "unknown-config", configKey: configKey } );
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const errors = [];
|
|
237
|
+
const validateSchema = this.#schemaValidator( registration );
|
|
238
|
+
if ( !validateSchema( value ) ) {
|
|
239
|
+
for ( const error of ( validateSchema.errors || [] ) ) {
|
|
240
|
+
errors.push( { path: error.dataPath || instancePathToDataPath( error.instancePath ), message: error.message || "schema violation", code: "schema", params: error.params } );
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return { valid: errors.length === 0, errors: errors };
|
|
244
|
+
}
|
|
245
|
+
|
|
220
246
|
/* Private interface */
|
|
221
247
|
|
|
222
248
|
/**
|
|
@@ -21,6 +21,7 @@ const configDrift = require( "#config-drift" );
|
|
|
21
21
|
/** @import ConfigChangeNotifier from "#config-change-notifier" */
|
|
22
22
|
/** @import ConfigRegistry from "#config-registry" */
|
|
23
23
|
/** @import ConfigStore from "#config-store" */
|
|
24
|
+
/** @import { ConfigValidationIssue } from "#config-registry" */
|
|
24
25
|
|
|
25
26
|
/**
|
|
26
27
|
* Orchestrates validated, versioned configuration edits on top of {@link ConfigStore} and {@link ConfigRegistry}.
|
|
@@ -374,6 +375,37 @@ class ConfigService {
|
|
|
374
375
|
} ) );
|
|
375
376
|
}
|
|
376
377
|
|
|
378
|
+
/**
|
|
379
|
+
* Reports every registered document whose **stored** value no longer satisfies its registered schema.
|
|
380
|
+
* <br/>
|
|
381
|
+
* Nothing validates a stored value on the way out: the store hands back whatever it holds, and a consumer
|
|
382
|
+
* freezes it into its config exports. That is fine until a release tightens a schema — adds a required
|
|
383
|
+
* top-level key, say — at which point a deployment seeded before the change keeps serving a document that
|
|
384
|
+
* cannot be saved any more. The failure surfaces much later, as an admin edit rejected for a key the admin
|
|
385
|
+
* never touched, in a screen that has nothing to do with the change.
|
|
386
|
+
* <br/>
|
|
387
|
+
* This answers that question at a moment when it is cheap to act on. It is deliberately schema-only (see
|
|
388
|
+
* {@link ConfigRegistry#validateSchema}) and deliberately read-only: reconciling a stale document is what the
|
|
389
|
+
* drift panel is for, and an automatic repair here would write config outside the audited change-set machinery.
|
|
390
|
+
* A document that has never been stored is not a violation and is omitted.
|
|
391
|
+
*
|
|
392
|
+
* @method
|
|
393
|
+
* @returns {Promise<Array<{configKey: string, errors: ConfigValidationIssue[]}>>} Empty when every stored
|
|
394
|
+
* document validates.
|
|
395
|
+
* @public
|
|
396
|
+
*/
|
|
397
|
+
listSchemaViolations() {
|
|
398
|
+
return Promise.all( this.#registry.list().map( ( configKey ) => {
|
|
399
|
+
return this.#store.getCurrent( configKey ).then( ( current ) => {
|
|
400
|
+
if ( !current ) {
|
|
401
|
+
return null;
|
|
402
|
+
}
|
|
403
|
+
const result = this.#registry.validateSchema( configKey, current.value );
|
|
404
|
+
return result.valid ? null : { configKey: configKey, errors: result.errors };
|
|
405
|
+
} );
|
|
406
|
+
} ) ).then( ( results ) => results.filter( Boolean ) );
|
|
407
|
+
}
|
|
408
|
+
|
|
377
409
|
/**
|
|
378
410
|
* Applies the registered file defaults for the given documents, as a single validated change-set.
|
|
379
411
|
* <br/>
|
|
@@ -60,6 +60,17 @@ function applyWebConfigEnvOverrides( config, env = process.env ) {
|
|
|
60
60
|
config.cookies = config.cookies || {};
|
|
61
61
|
config.cookies.secret = env.TI_WEB_COOKIE_SECRET;
|
|
62
62
|
}
|
|
63
|
+
if ( env.TI_WEB_SESSION_IDLE_TIMEOUT !== undefined ) {
|
|
64
|
+
// MINUTES, because that is the unit a deployment actually reasons in — and because the millisecond field it
|
|
65
|
+
// feeds is what went wrong here in the first place: `604800` was written into `cookies.maxAge` meaning seven
|
|
66
|
+
// days, and express-session read it as 604800 MILLISECONDS, giving every user a ten-minute session. Naming
|
|
67
|
+
// the unit in the variable and converting here keeps that mistake from being expressible.
|
|
68
|
+
const minutes = Number( env.TI_WEB_SESSION_IDLE_TIMEOUT );
|
|
69
|
+
if ( Number.isInteger( minutes ) && minutes > 0 ) {
|
|
70
|
+
config.cookies = config.cookies || {};
|
|
71
|
+
config.cookies.maxAge = minutes * 60 * 1000;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
63
74
|
if ( env.TI_WEB_AUTH_METHODS !== undefined ) {
|
|
64
75
|
config.auth = config.auth || {};
|
|
65
76
|
config.auth.enabledMethods = env.TI_WEB_AUTH_METHODS.split( "," ).map( ( method ) => method.trim() ).filter( ( method ) => method.length > 0 );
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ti-engine/web-framework",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.27.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
|
"keywords": [
|
|
6
6
|
"ti-engine",
|
|
@@ -142,6 +142,26 @@ declare class ConfigRegistry {
|
|
|
142
142
|
valid: boolean;
|
|
143
143
|
errors: ConfigValidationIssue[];
|
|
144
144
|
}>;
|
|
145
|
+
/**
|
|
146
|
+
* Validates a value against a document's registered schema **only**, skipping the semantic validators.
|
|
147
|
+
* <br/>
|
|
148
|
+
* Separate from {@link ConfigRegistry#validate} because the two answer different questions. A semantic validator
|
|
149
|
+
* may need a {@link ValidatorContext} the caller has no reason to build, and several of them are about an edit
|
|
150
|
+
* (did the version move? does this removal orphan a reference?) rather than about the document standing alone.
|
|
151
|
+
* A caller that just wants to know whether a value is structurally admissible — a boot-time check on what the
|
|
152
|
+
* store already holds, say — needs this half and not the other.
|
|
153
|
+
*
|
|
154
|
+
* @method
|
|
155
|
+
* @param {string} configKey
|
|
156
|
+
* @param {Object} value
|
|
157
|
+
* @returns {{valid: boolean, errors: ConfigValidationIssue[]}}
|
|
158
|
+
* @throws {TiException.E_WEB_INVALID_REQUEST_PARAMETERS} If the document is not registered.
|
|
159
|
+
* @public
|
|
160
|
+
*/
|
|
161
|
+
validateSchema(configKey: string, value: Object): {
|
|
162
|
+
valid: boolean;
|
|
163
|
+
errors: ConfigValidationIssue[];
|
|
164
|
+
};
|
|
145
165
|
}
|
|
146
166
|
declare namespace ConfigRegistry {
|
|
147
167
|
export { instance };
|
|
@@ -2,9 +2,11 @@ export = ConfigService;
|
|
|
2
2
|
import type ConfigChangeNotifier from "#config-change-notifier";
|
|
3
3
|
import type ConfigRegistry from "#config-registry";
|
|
4
4
|
import type ConfigStore from "#config-store";
|
|
5
|
+
import type { ConfigValidationIssue } from "#config-registry";
|
|
5
6
|
/** @import ConfigChangeNotifier from "#config-change-notifier" */
|
|
6
7
|
/** @import ConfigRegistry from "#config-registry" */
|
|
7
8
|
/** @import ConfigStore from "#config-store" */
|
|
9
|
+
/** @import { ConfigValidationIssue } from "#config-registry" */
|
|
8
10
|
/**
|
|
9
11
|
* Orchestrates validated, versioned configuration edits on top of {@link ConfigStore} and {@link ConfigRegistry}.
|
|
10
12
|
*
|
|
@@ -223,6 +225,29 @@ declare class ConfigService {
|
|
|
223
225
|
* @public
|
|
224
226
|
*/
|
|
225
227
|
listDrift(): Promise<Array<Object>>;
|
|
228
|
+
/**
|
|
229
|
+
* Reports every registered document whose **stored** value no longer satisfies its registered schema.
|
|
230
|
+
* <br/>
|
|
231
|
+
* Nothing validates a stored value on the way out: the store hands back whatever it holds, and a consumer
|
|
232
|
+
* freezes it into its config exports. That is fine until a release tightens a schema — adds a required
|
|
233
|
+
* top-level key, say — at which point a deployment seeded before the change keeps serving a document that
|
|
234
|
+
* cannot be saved any more. The failure surfaces much later, as an admin edit rejected for a key the admin
|
|
235
|
+
* never touched, in a screen that has nothing to do with the change.
|
|
236
|
+
* <br/>
|
|
237
|
+
* This answers that question at a moment when it is cheap to act on. It is deliberately schema-only (see
|
|
238
|
+
* {@link ConfigRegistry#validateSchema}) and deliberately read-only: reconciling a stale document is what the
|
|
239
|
+
* drift panel is for, and an automatic repair here would write config outside the audited change-set machinery.
|
|
240
|
+
* A document that has never been stored is not a violation and is omitted.
|
|
241
|
+
*
|
|
242
|
+
* @method
|
|
243
|
+
* @returns {Promise<Array<{configKey: string, errors: ConfigValidationIssue[]}>>} Empty when every stored
|
|
244
|
+
* document validates.
|
|
245
|
+
* @public
|
|
246
|
+
*/
|
|
247
|
+
listSchemaViolations(): Promise<Array<{
|
|
248
|
+
configKey: string;
|
|
249
|
+
errors: ConfigValidationIssue[];
|
|
250
|
+
}>>;
|
|
226
251
|
/**
|
|
227
252
|
* Applies the registered file defaults for the given documents, as a single validated change-set.
|
|
228
253
|
* <br/>
|