@ti-engine/web-framework 1.21.0 → 1.24.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 +82 -0
- package/README.md +52 -0
- package/bin/build/hash-password.js +36 -0
- package/bin/config/local-users.example.json +8 -0
- package/bin/localization/web-server-labels.json +4 -0
- package/bin/static/fragments/frame-login.html +6 -1
- package/bin/static/scripts/ti-framework.css +4 -0
- package/bin/static/scripts/ti-framework.js +23 -0
- package/bin/web-server.js +21 -1
- package/bin/web-server.json +3 -0
- package/components/admin-config-handlers.js +31 -0
- package/components/auth-manager.js +159 -18
- package/components/config-drift.js +141 -0
- package/components/config-service.js +97 -0
- package/components/local-user-directory.js +428 -0
- package/components/web-config-env.js +6 -1
- package/components/web-handlers.js +10 -2
- package/package.json +19 -2
- package/types/bin/web-server.d.ts +19 -1
- package/types/components/admin-config-handlers.d.ts +3 -0
- package/types/components/auth-manager.d.ts +7 -0
- package/types/components/config-drift.d.ts +27 -0
- package/types/components/config-service.d.ts +59 -0
- package/types/components/local-user-directory.d.ts +111 -0
- package/types/components/web-config-env.d.ts +1 -1
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The ti-engine is an open source, free to use—both for personal and commercial projects—framework for the creation of microservice-based solutions using node.js.
|
|
3
|
+
* Copyright © 2021-2026 Boris Kostadinov <kostadinov.boris@gmail.com>
|
|
4
|
+
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
|
|
5
|
+
* This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
|
6
|
+
* You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Pure structural diff between a configuration document's file default and the value currently held in the store.
|
|
11
|
+
* <br/>
|
|
12
|
+
* The store seeds from a file default exactly once ({@link ConfigStore#seedIfEmpty}), so a release that changes a
|
|
13
|
+
* config file changes nothing an already-seeded deployment serves. This module is the detection half of the remedy:
|
|
14
|
+
* it answers "how does the shipped default differ from what this deployment is running", in terms legible enough for
|
|
15
|
+
* an admin to judge whether applying it is safe.
|
|
16
|
+
* <br/>
|
|
17
|
+
* No I/O — the caller supplies both values.
|
|
18
|
+
*
|
|
19
|
+
* @module config-drift
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const { isDeepStrictEqual } = require( "node:util" );
|
|
23
|
+
|
|
24
|
+
const STATUS_IN_SYNC = "in-sync";
|
|
25
|
+
const STATUS_DRIFTED = "drifted";
|
|
26
|
+
const STATUS_ABSENT = "absent";
|
|
27
|
+
const STATUS_NO_DEFAULT = "no-default";
|
|
28
|
+
|
|
29
|
+
const KIND_ADDED = "added";
|
|
30
|
+
const KIND_REMOVED = "removed";
|
|
31
|
+
const KIND_CHANGED = "changed";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @typedef {Object} ConfigDriftEntry
|
|
35
|
+
* @property {string} path Dot/bracket data path, matching the dialect used for schema validation issues.
|
|
36
|
+
* @property {string} kind One of "added", "removed", "changed".
|
|
37
|
+
* @property {number} [addedMembers] For a primitive array: how many members the file default adds.
|
|
38
|
+
* @property {number} [removedMembers] For a primitive array: how many members the file default drops.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @param {*} value
|
|
43
|
+
* @returns {boolean} True for a non-null, non-array object.
|
|
44
|
+
*/
|
|
45
|
+
function isPlainObject( value ) {
|
|
46
|
+
return value !== null && typeof value === "object" && !Array.isArray( value );
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @param {*} value
|
|
51
|
+
* @returns {boolean} True for an array holding no objects (a code list, a set of flags, …).
|
|
52
|
+
*/
|
|
53
|
+
function isPrimitiveArray( value ) {
|
|
54
|
+
return Array.isArray( value ) && value.every( ( item ) => item === null || typeof item !== "object" );
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* @param {string} base
|
|
59
|
+
* @param {string} key
|
|
60
|
+
* @returns {string} The child path, numeric keys in bracket notation.
|
|
61
|
+
*/
|
|
62
|
+
function joinPath( base, key ) {
|
|
63
|
+
return ( /^\d+$/.test( key ) ) ? `${ base }[${ key }]` : `${ base }.${ key }`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Recursive worker. Appends to `entries` in place.
|
|
68
|
+
*
|
|
69
|
+
* @param {*} fileValue
|
|
70
|
+
* @param {*} storedValue
|
|
71
|
+
* @param {string} path
|
|
72
|
+
* @param {ConfigDriftEntry[]} entries
|
|
73
|
+
*/
|
|
74
|
+
function diffValue( fileValue, storedValue, path, entries ) {
|
|
75
|
+
if ( isPlainObject( fileValue ) && isPlainObject( storedValue ) ) {
|
|
76
|
+
const keys = new Set( [ ...Object.keys( fileValue ), ...Object.keys( storedValue ) ] );
|
|
77
|
+
for ( const key of keys ) {
|
|
78
|
+
const childPath = joinPath( path, key );
|
|
79
|
+
const inFile = Object.prototype.hasOwnProperty.call( fileValue, key );
|
|
80
|
+
const inStored = Object.prototype.hasOwnProperty.call( storedValue, key );
|
|
81
|
+
if ( inFile && !inStored ) {
|
|
82
|
+
entries.push( { path: childPath, kind: KIND_ADDED } );
|
|
83
|
+
} else if ( !inFile && inStored ) {
|
|
84
|
+
entries.push( { path: childPath, kind: KIND_REMOVED } );
|
|
85
|
+
} else {
|
|
86
|
+
diffValue( fileValue[ key ], storedValue[ key ], childPath, entries );
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// A list of codes is a set, not a sequence: report which members moved, and treat a pure reorder as no change.
|
|
93
|
+
// This is what turns "role-family-competencies changed" into the far more useful "QE +27 codes".
|
|
94
|
+
// <br/>
|
|
95
|
+
// Set semantics also mean MULTIPLICITY is ignored — `[ "A", "A" ]` and `[ "A" ]` compare as in-sync even though
|
|
96
|
+
// applying would replace one with the other. That is deliberate for a list of codes, where a repeated member is
|
|
97
|
+
// a data error rather than a meaningful difference, and it is unreachable for the documents shipped here: the
|
|
98
|
+
// array-valued competence schemas all declare `uniqueItems`, so a duplicate cannot pass validation on save. A
|
|
99
|
+
// consumer whose arrays are genuinely multisets should not model them as primitive arrays for this diff.
|
|
100
|
+
if ( isPrimitiveArray( fileValue ) && isPrimitiveArray( storedValue ) ) {
|
|
101
|
+
const storedMembers = new Set( storedValue );
|
|
102
|
+
const fileMembers = new Set( fileValue );
|
|
103
|
+
const addedMembers = fileValue.filter( ( item ) => !storedMembers.has( item ) ).length;
|
|
104
|
+
const removedMembers = storedValue.filter( ( item ) => !fileMembers.has( item ) ).length;
|
|
105
|
+
if ( addedMembers > 0 || removedMembers > 0 ) {
|
|
106
|
+
entries.push( { path: path, kind: KIND_CHANGED, addedMembers: addedMembers, removedMembers: removedMembers } );
|
|
107
|
+
}
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if ( !isDeepStrictEqual( fileValue, storedValue ) ) {
|
|
112
|
+
entries.push( { path: path, kind: KIND_CHANGED } );
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Diffs a document's registered file default against its stored value.
|
|
118
|
+
*
|
|
119
|
+
* @method
|
|
120
|
+
* @param {*} fileDefault The value registered with {@link ConfigRegistry#register}; `undefined` when none was.
|
|
121
|
+
* @param {*} storedValue The value currently in the store; `null`/`undefined` when never written.
|
|
122
|
+
* @returns {{status: string, entries: ConfigDriftEntry[], counts: {added: number, removed: number, changed: number}}}
|
|
123
|
+
* @public
|
|
124
|
+
*/
|
|
125
|
+
module.exports.diffDocument = ( fileDefault, storedValue ) => {
|
|
126
|
+
if ( fileDefault === undefined ) {
|
|
127
|
+
return { status: STATUS_NO_DEFAULT, entries: [], counts: { added: 0, removed: 0, changed: 0 } };
|
|
128
|
+
}
|
|
129
|
+
if ( storedValue === undefined || storedValue === null ) {
|
|
130
|
+
return { status: STATUS_ABSENT, entries: [], counts: { added: 0, removed: 0, changed: 0 } };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const entries = [];
|
|
134
|
+
diffValue( fileDefault, storedValue, "", entries );
|
|
135
|
+
|
|
136
|
+
const counts = { added: 0, removed: 0, changed: 0 };
|
|
137
|
+
for ( const entry of entries ) {
|
|
138
|
+
counts[ entry.kind ] += 1;
|
|
139
|
+
}
|
|
140
|
+
return { status: ( entries.length > 0 ) ? STATUS_DRIFTED : STATUS_IN_SYNC, entries: entries, counts: counts };
|
|
141
|
+
};
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
const exceptions = require( "@ti-engine/core/exceptions" );
|
|
10
|
+
const configDrift = require( "#config-drift" );
|
|
10
11
|
|
|
11
12
|
/** @import ConfigChangeNotifier from "#config-change-notifier" */
|
|
12
13
|
/** @import ConfigRegistry from "#config-registry" */
|
|
@@ -307,6 +308,102 @@ class ConfigService {
|
|
|
307
308
|
} ) );
|
|
308
309
|
}
|
|
309
310
|
|
|
311
|
+
/* Public interface — drift against file defaults */
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Compares a document's registered file default against the value currently in the store. This is how a
|
|
315
|
+
* configuration change shipped in a release becomes visible on a deployment that was seeded before it — the
|
|
316
|
+
* store seeds only once, so a later file change is otherwise invisible.
|
|
317
|
+
*
|
|
318
|
+
* @method
|
|
319
|
+
* @param {string} configKey
|
|
320
|
+
* @returns {Promise<{configKey: string, status: string, counts: Object, entries: Array, storedVersion: number, editable: boolean, label: string}>}
|
|
321
|
+
* @throws {TiException.E_WEB_INVALID_REQUEST_PARAMETERS} If the document is not registered.
|
|
322
|
+
* @public
|
|
323
|
+
*/
|
|
324
|
+
getDrift( configKey ) {
|
|
325
|
+
if ( !this.#registry.has( configKey ) ) {
|
|
326
|
+
return Promise.reject( exceptions.raise( exceptions.exceptionCode.E_WEB_INVALID_REQUEST_PARAMETERS, { reason: "unknown-config", configKey: configKey } ) );
|
|
327
|
+
}
|
|
328
|
+
const metadata = this.#registry.metadataFor( configKey ) || {};
|
|
329
|
+
return this.#store.getCurrent( configKey ).then( ( current ) => {
|
|
330
|
+
const diff = configDrift.diffDocument( this.#registry.getDefault( configKey ), current ? current.value : null );
|
|
331
|
+
return {
|
|
332
|
+
configKey: configKey,
|
|
333
|
+
status: diff.status,
|
|
334
|
+
counts: diff.counts,
|
|
335
|
+
entries: diff.entries,
|
|
336
|
+
storedVersion: current ? current.version : 0,
|
|
337
|
+
editable: metadata.editable !== false,
|
|
338
|
+
label: metadata.label || configKey
|
|
339
|
+
};
|
|
340
|
+
} );
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Drift summaries for every registered document. This still computes each document's full entry list internally
|
|
345
|
+
* (it delegates to {@link ConfigService#getDrift} per document) — the saving is in the response shape, not the
|
|
346
|
+
* computation: `entries` is omitted here to keep the payload small enough for a landing screen and a startup log,
|
|
347
|
+
* where only the counts are shown.
|
|
348
|
+
*
|
|
349
|
+
* @method
|
|
350
|
+
* @returns {Promise<Array<Object>>}
|
|
351
|
+
* @public
|
|
352
|
+
*/
|
|
353
|
+
listDrift() {
|
|
354
|
+
return Promise.all( this.#registry.list().map( ( configKey ) => {
|
|
355
|
+
return this.getDrift( configKey ).then( ( drift ) => ( {
|
|
356
|
+
configKey: drift.configKey,
|
|
357
|
+
status: drift.status,
|
|
358
|
+
counts: drift.counts,
|
|
359
|
+
storedVersion: drift.storedVersion,
|
|
360
|
+
editable: drift.editable,
|
|
361
|
+
label: drift.label
|
|
362
|
+
} ) );
|
|
363
|
+
} ) );
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Applies the registered file defaults for the given documents, as a single validated change-set.
|
|
368
|
+
* <br/>
|
|
369
|
+
* Routing through {@link ConfigService#applyEdits} is deliberate: the application is schema- and
|
|
370
|
+
* semantically validated, versioned, correlated into one change-set, added to the audit feed, and restorable —
|
|
371
|
+
* and, because a validator sees its siblings at their *pending* value, interdependent documents applied
|
|
372
|
+
* together validate against each other rather than against the stale stored state.
|
|
373
|
+
*
|
|
374
|
+
* @method
|
|
375
|
+
* @param {string[]} configKeys
|
|
376
|
+
* @param {Object} meta
|
|
377
|
+
* @param {string} meta.adminID
|
|
378
|
+
* @param {string} [meta.note]
|
|
379
|
+
* @returns {Promise<{ok: true, changeSetID: string, versions: Object}|{ok: false, errors: Object}>}
|
|
380
|
+
* @throws {TiException.E_WEB_INVALID_REQUEST_PARAMETERS} On bad input, an unknown key, or a key with no default.
|
|
381
|
+
* @public
|
|
382
|
+
*/
|
|
383
|
+
applyDefaults( configKeys, meta ) {
|
|
384
|
+
if ( !Array.isArray( configKeys ) || configKeys.length === 0 || !meta || !meta.adminID ) {
|
|
385
|
+
return Promise.reject( exceptions.raise( exceptions.exceptionCode.E_WEB_INVALID_REQUEST_PARAMETERS, { reason: "invalid-apply-defaults-input" } ) );
|
|
386
|
+
}
|
|
387
|
+
const keys = Array.from( new Set( configKeys ) );
|
|
388
|
+
const unknown = keys.filter( ( key ) => !this.#registry.has( key ) );
|
|
389
|
+
if ( unknown.length > 0 ) {
|
|
390
|
+
return Promise.reject( exceptions.raise( exceptions.exceptionCode.E_WEB_INVALID_REQUEST_PARAMETERS, { reason: "unknown-config", configKeys: unknown } ) );
|
|
391
|
+
}
|
|
392
|
+
const withoutDefault = keys.filter( ( key ) => this.#registry.getDefault( key ) === undefined );
|
|
393
|
+
if ( withoutDefault.length > 0 ) {
|
|
394
|
+
return Promise.reject( exceptions.raise( exceptions.exceptionCode.E_WEB_INVALID_REQUEST_PARAMETERS, { reason: "no-default", configKeys: withoutDefault } ) );
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
return Promise.all( keys.map( ( key ) => this.#store.getCurrent( key ) ) ).then( ( currents ) => {
|
|
398
|
+
const edits = keys.map( ( key, index ) => ( {
|
|
399
|
+
configKey: key,
|
|
400
|
+
value: this.#registry.getDefault( key ),
|
|
401
|
+
expectedVersion: currents[ index ] ? currents[ index ].version : 0
|
|
402
|
+
} ) );
|
|
403
|
+
return this.applyEdits( edits, { adminID: meta.adminID, note: meta.note || "applied file defaults" } );
|
|
404
|
+
} );
|
|
405
|
+
}
|
|
406
|
+
|
|
310
407
|
/**
|
|
311
408
|
* Seeds a document's default value into the store only if it has never been written (idempotent bootstrap).
|
|
312
409
|
* Used by an application to bring its file defaults into the store at startup before serving live config.
|