@ti-engine/web-framework 1.20.1 → 1.23.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.
@@ -0,0 +1,428 @@
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
+ const crypto = require( "node:crypto" );
10
+ const tools = require( "@ti-engine/core/tools" );
11
+ const cache = require( "@ti-engine/core/cache" );
12
+
13
+ /**
14
+ * @typedef {Object} LocalUserRecord
15
+ * @property {string} userID
16
+ * @property {string} username
17
+ * @property {string} email
18
+ * @property {string} name
19
+ * @property {string} passwordHash
20
+ * @property {boolean} disabled
21
+ */
22
+
23
+ const ALGORITHM = "scrypt";
24
+
25
+ const CACHE_KEY = "ti:web:auth:local-users";
26
+
27
+ // scrypt at N=16384, r=8 needs 128 * N * r = 16 MiB, comfortably inside node's 32 MiB default `maxmem`.
28
+ const HASH_DEFAULTS = Object.freeze( { N: 16384, r: 8, p: 1, saltBytes: 16, keyBytes: 64 } );
29
+
30
+ // Minimums enforced by decodeHash so a truncated or hand-edited record is rejected at load time — where
31
+ // parseRecords can report it — rather than silently authenticating with far less entropy than the encoding
32
+ // implies (e.g. a copy-pasted base64 key cut short: Buffer.from() shortens it instead of throwing).
33
+ const MIN_SALT_BYTES = 8;
34
+ const MIN_KEY_BYTES = 32;
35
+
36
+ // p multiplies scrypt's CPU cost linearly and sits outside node's `maxmem` guard, so a mistyped value would
37
+ // hog a threadpool slot proportionally with no upper bound otherwise. The default is 1; 16 is ample headroom.
38
+ const MAX_P = 16;
39
+
40
+ // The lower bound a stored hash's N must clear before it is trusted, mirroring the salt/key length floors above:
41
+ // the security level of a record must come from policy, not from whatever survived into the stored string.
42
+ // Hardcoded to the same cost as HASH_DEFAULTS.N — deliberately NOT derived from it (e.g. `HASH_DEFAULTS.N`
43
+ // itself), so an edit to HASH_DEFAULTS.N in isolation is caught by the invariant check below instead of the
44
+ // floor silently tracking whatever the default becomes. Without this floor, `N & (N - 1)` still accepts any
45
+ // power of two, so a truncated or mistyped N (16384 -> 16) loads clean and verifies almost for free.
46
+ const MIN_N = 16384;
47
+
48
+ // A hard ceiling on N, kept for two narrow reasons: `N & (N - 1)` coerces both operands to int32 to test the
49
+ // power-of-two invariant, so it is only reliable strictly below 2^31 — at or above that a non-power-of-two value
50
+ // can pass the check anyway — and N is scrypt's dominant CPU-cost multiplier with no cap of its own.
51
+ // NOTE: this is NOT the effective ceiling, and an earlier version of this comment wrongly claimed it prevented
52
+ // the "loads clean, then fails every verifyPassword call forever" lockout. It does not: the memory budget below
53
+ // binds first by a wide margin. At the shipped r=8, N=32768 — a mere 2x the default and 32x below this value —
54
+ // already exceeds the memory limit. Treat MAX_N as a backstop, not as a description of the usable range.
55
+ const MAX_N = 2 ** 20;
56
+
57
+ // scrypt's memory requirement, and the budget it must fit inside.
58
+ //
59
+ // `crypto.scrypt` rejects parameters needing more than `maxmem`, which Node defaults to 32 MiB. Neither
60
+ // `deriveKey` nor `hashPassword` passes `maxmem`, so that default governs — deliberately: raising it would widen
61
+ // what this published package permits and multiply peak memory across concurrent logins, each holding its budget
62
+ // on a threadpool slot. The bound therefore matches the runtime's real limit rather than a limit of our choosing.
63
+ //
64
+ // The formula is the one OpenSSL actually applies — `128 * r * (N + 2 + p)`, the V array plus the B buffer — NOT
65
+ // the frequently-quoted `128 * N * r`. That distinction is load-bearing: for N=16384/r=16 the naive form computes
66
+ // exactly 33554432, at or under the 32 MiB budget, so a check written from it would ADMIT the very parameters it
67
+ // exists to reject, while the true requirement is 33560576 and OpenSSL refuses. Verified against
68
+ // `crypto.scryptSync` across the boundary; the accepted/rejected split matches this expression exactly.
69
+ //
70
+ // Without this bound a record whose parameters exceed the budget loads with zero reported problems and then makes
71
+ // every verification fail forever — `verifyPassword`'s `.catch( () => false )` turns the
72
+ // ERR_CRYPTO_INVALID_SCRYPT_PARAMS into an ordinary "wrong password", so the account is permanently locked out
73
+ // and indistinguishable from a typo. With it, the record is rejected at load and named in the operator's warning.
74
+ const SCRYPT_MAX_MEMORY_BYTES = 32 * 1024 * 1024;
75
+
76
+ /**
77
+ * The memory `crypto.scrypt` requires for the given cost parameters, in bytes.
78
+ *
79
+ * @method
80
+ * @param {number} N
81
+ * @param {number} r
82
+ * @param {number} p
83
+ * @returns {number}
84
+ * @private
85
+ */
86
+ function scryptMemoryRequirement( N, r, p ) {
87
+ return 128 * r * ( N + 2 + p );
88
+ }
89
+
90
+ // Invariant: MIN_N must never be stricter than the cost this module itself mints new hashes at, or a hash
91
+ // produced by hashPassword() today could be rejected by decodeHash() tomorrow. Asserted at module load, not
92
+ // only documented in the comment above, so a future edit to either constant that breaks the relationship fails
93
+ // loudly at require() time instead of silently shipping a directory that can never authenticate its own
94
+ // freshly-hashed passwords.
95
+ if ( MIN_N > HASH_DEFAULTS.N ) {
96
+ throw new Error( "local-user-directory: MIN_N must not exceed HASH_DEFAULTS.N — every hash minted by hashPassword() must clear decodeHash()'s own floor" );
97
+ }
98
+
99
+ /**
100
+ * Derives a key with scrypt. Asynchronous on purpose: `scryptSync` blocks the event loop for roughly 100 ms at
101
+ * these parameters, which on a login endpoint is a self-inflicted denial of service.
102
+ *
103
+ * @param {string} password
104
+ * @param {Buffer} salt
105
+ * @param {{N: number, r: number, p: number}} parameters
106
+ * @param {number} keyBytes
107
+ * @returns {Promise<Buffer>}
108
+ */
109
+ function deriveKey( password, salt, parameters, keyBytes ) {
110
+ return new Promise( ( resolve, reject ) => {
111
+ crypto.scrypt( password, salt, keyBytes, parameters, ( error, key ) => {
112
+ if ( error ) {
113
+ reject( error );
114
+ } else {
115
+ resolve( key );
116
+ }
117
+ } );
118
+ } );
119
+ }
120
+
121
+ /**
122
+ * Splits an encoded hash into its parameters and material, or returns `null` when it is not a recognized
123
+ * encoding — including a structurally valid one whose cost parameters or material fall outside the minimums
124
+ * this module enforces (a non-power-of-two `N`, an excessive `p`, or salt/key material too short to trust).
125
+ *
126
+ * @param {string} encoded
127
+ * @returns {{parameters: {N: number, r: number, p: number}, salt: Buffer, key: Buffer}|null}
128
+ */
129
+ function decodeHash( encoded ) {
130
+ if ( typeof encoded !== "string" ) {
131
+ return null;
132
+ }
133
+ const parts = encoded.split( "$" );
134
+ if ( parts.length !== 6 || parts[ 0 ] !== ALGORITHM ) {
135
+ return null;
136
+ }
137
+ const [ , rawN, rawR, rawP, rawSalt, rawKey ] = parts;
138
+ const N = Number( rawN );
139
+ const r = Number( rawR );
140
+ const p = Number( rawP );
141
+ // N is bounded on both sides by MIN_N/MAX_N (see their comments above) before the power-of-two test below —
142
+ // a value outside that range must never reach it, since the bitwise check alone is silently unreliable past
143
+ // the int32 boundary and provides no floor against a cost that is technically a power of two but far too
144
+ // cheap to trust.
145
+ if ( !Number.isInteger( N ) || !Number.isInteger( r ) || !Number.isInteger( p ) || N < MIN_N || N > MAX_N || r < 1 || p < 1 || p > MAX_P ) {
146
+ return null;
147
+ }
148
+ // crypto.scrypt requires N to be a power of two; anything else throws ERR_CRYPTO_INVALID_SCRYPT_PARAMS at
149
+ // derive time. verifyPassword's `.catch(() => false)` swallows that into an ordinary "wrong password", so
150
+ // without this check an operator would see permanent, silent failed logins with nothing reported anywhere.
151
+ if ( ( N & ( N - 1 ) ) !== 0 ) {
152
+ return null;
153
+ }
154
+ // The memory bound. Reached only once N/r/p are individually sane, because it is the combination that matters:
155
+ // every one of N=16384/r=16, N=32768/r=8 and N=65536/r=8 has each value in range yet needs more than the
156
+ // budget, and each one would otherwise load clean and then never verify again. There is deliberately no
157
+ // separate ceiling on `r` — r only matters through this requirement, and a standalone limit would either
158
+ // duplicate this one or contradict it.
159
+ if ( scryptMemoryRequirement( N, r, p ) > SCRYPT_MAX_MEMORY_BYTES ) {
160
+ return null;
161
+ }
162
+ try {
163
+ const salt = Buffer.from( rawSalt, "base64" );
164
+ const key = Buffer.from( rawKey, "base64" );
165
+ // Minimum lengths, not just non-empty: the security level of a record must come from policy, not from
166
+ // whatever happened to survive into the stored string. A truncated key still decodes without error
167
+ // (Buffer.from() shortens rather than throwing on invalid/incomplete base64), so length is the only
168
+ // signal left to catch it.
169
+ if ( salt.length < MIN_SALT_BYTES || key.length < MIN_KEY_BYTES ) {
170
+ return null;
171
+ }
172
+ return { parameters: { N: N, r: r, p: p }, salt: salt, key: key };
173
+ } catch {
174
+ return null;
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Hashes a password for storage in a local-users file. Synchronous because its only caller is the one-shot CLI,
180
+ * where blocking is free — never call it on a request path.
181
+ *
182
+ * @method
183
+ * @param {string} password
184
+ * @returns {string} The encoded hash: `scrypt$N$r$p$salt$hash`, base64 salt and key.
185
+ * @throws {TypeError} If `password` is empty or not a string — `verifyPassword` refuses empty passwords, so
186
+ * hashing one here would only mint a hash that can never be logged into.
187
+ * @public
188
+ */
189
+ function hashPassword( password ) {
190
+ if ( typeof password !== "string" || password.length === 0 ) {
191
+ throw new TypeError( "hashPassword requires a non-empty string password" );
192
+ }
193
+ const salt = crypto.randomBytes( HASH_DEFAULTS.saltBytes );
194
+ const parameters = { N: HASH_DEFAULTS.N, r: HASH_DEFAULTS.r, p: HASH_DEFAULTS.p };
195
+ const key = crypto.scryptSync( password, salt, HASH_DEFAULTS.keyBytes, parameters );
196
+ return [ ALGORITHM, parameters.N, parameters.r, parameters.p, salt.toString( "base64" ), key.toString( "base64" ) ].join( "$" );
197
+ }
198
+
199
+ /**
200
+ * Verifies a password against an encoded hash. The cost parameters come from the stored string rather than the
201
+ * current defaults, so raising the defaults never invalidates an existing hash.
202
+ *
203
+ * @method
204
+ * @param {string} password
205
+ * @param {string} encoded
206
+ * @returns {Promise<boolean>} `false` for a malformed encoding or an absent password — never a throw, because a
207
+ * bad stored value must read as "does not match", not as a server error on the login path.
208
+ * @public
209
+ */
210
+ function verifyPassword( password, encoded ) {
211
+ const decoded = decodeHash( encoded );
212
+ if ( !decoded || typeof password !== "string" || password.length === 0 ) {
213
+ return Promise.resolve( false );
214
+ }
215
+ return deriveKey( password, decoded.salt, decoded.parameters, decoded.key.length )
216
+ // Compared as base64 strings, not raw Buffers: constantTimeEquals coerces each argument with
217
+ // `String(x || "")`, which would utf8-decode a key Buffer lossily (through U+FFFD replacement for any
218
+ // byte sequence that is not valid UTF-8) instead of comparing its bytes — silently breaking the
219
+ // comparison. Base64 text round-trips through String() exactly, so it stays safe to pass here.
220
+ .then( ( key ) => tools.constantTimeEquals( key.toString( "base64" ), decoded.key.toString( "base64" ) ) )
221
+ .catch( () => false );
222
+ }
223
+
224
+ // Usernames that JavaScript's object model treats specially, rejected here because the storage layer this
225
+ // module writes through cannot represent all of them safely — reusing the exact trio ('__proto__',
226
+ // 'constructor', 'prototype') this codebase already treats as reserved at every other prototype-pollution
227
+ // boundary (see the CA-91 employee field-path guards in packages/competence). Verified empirically per name,
228
+ // not assumed uniformly:
229
+ // - '__proto__' is the one that corrupted storage, in @ti-engine/core **before 1.11.0**.
230
+ // `cache.instance.setJSON` serializes through `tools.stringifyJSON` —
231
+ // `JSON.stringify( _.toPlainObject( decycle( value ) ) )` in @ti-engine/core/utils/tools.js. `decycle`'s
232
+ // object-copy branch built each replica with `newItem = {}; newItem[ name ] = derez( ... )`; for
233
+ // `name === "__proto__"` that assignment invoked the inherited accessor setter instead of creating an own key,
234
+ // repointing the replica's own prototype to the record. `_.toPlainObject` then flattened that prototype chain
235
+ // back into own keys, so the record's fields were spliced into the top level of the *entire* stored directory
236
+ // rather than merely dropped.
237
+ // **core 1.11.0 fixed that** (its `decycle` builds the replica with `Object.create( null )`), so against a
238
+ // current core the round-trip is intact — the test in local-user-directory.store.test.js now pins the fixed
239
+ // behaviour. The rejection stays regardless, and not as vague defence in depth: this package declares
240
+ // `"@ti-engine/core": "*"`, so a consumer of the published web-framework may pair it with any core, including
241
+ // a pre-1.11.0 one that still corrupts. web-framework cannot guarantee the serializer beneath it is fixed, so
242
+ // it must not accept a record it might be unable to represent.
243
+ // - 'constructor' and 'prototype' round-trip through that same pipeline correctly (verified the same way).
244
+ // 'constructor' is hazardous only for an *unguarded read* (`stored.constructor` resolves to the inherited
245
+ // Object constructor function when absent) — exactly what the hasOwnProperty guards in reconcile/
246
+ // findByUsername below exist to prevent — and 'prototype' collides with nothing in a plain object's
247
+ // prototype chain at all. Both are rejected anyway so this stays the same trio as everywhere else in the
248
+ // codebase, rather than a bespoke subset that has to be re-derived from this file's current implementation
249
+ // details every time something downstream changes.
250
+ // Do not "simplify" this back down to just '__proto__' on the assumption that only it is provably broken
251
+ // today — re-verify all three empirically first, the same way this comment's claims were verified, before
252
+ // removing any of them (including after @ti-engine/core's decycle/stringifyJSON pipeline is eventually fixed).
253
+ const RESERVED_USERNAMES = new Set( [ "__proto__", "constructor", "prototype" ] );
254
+
255
+ /**
256
+ * Validates raw file content into records, reporting why any entry was excluded. Never throws: a malformed row is
257
+ * data, not a crash, so one bad entry cannot take an instance down.
258
+ *
259
+ * @method
260
+ * @param {*} raw
261
+ * @returns {{records: LocalUserRecord[], problems: string[]}}
262
+ * @public
263
+ */
264
+ function parseRecords( raw ) {
265
+ const problems = [];
266
+ if ( !Array.isArray( raw ) ) {
267
+ return { records: [], problems: [ "the local users file must contain a JSON array of user records" ] };
268
+ }
269
+
270
+ const records = [];
271
+ const seen = new Set();
272
+ raw.forEach( ( entry, index ) => {
273
+ if ( !entry || typeof entry !== "object" || Array.isArray( entry ) ) {
274
+ problems.push( `entry ${ index } is not an object` );
275
+ return;
276
+ }
277
+ const username = typeof entry.username === "string" ? entry.username.trim() : "";
278
+ const email = typeof entry.email === "string" ? entry.email.trim() : "";
279
+ const passwordHash = typeof entry.passwordHash === "string" ? entry.passwordHash.trim() : "";
280
+
281
+ if ( !username ) {
282
+ problems.push( `entry ${ index } has no username` );
283
+ return;
284
+ }
285
+ // See the RESERVED_USERNAMES comment above: the storage layer cannot represent one of these three
286
+ // names without corrupting the directory (confirmed for '__proto__'; the other two are rejected for
287
+ // consistency), so a record using one is refused here — at load, where an operator sees why — rather
288
+ // than accepted and silently corrupted or lost the first time it is actually written.
289
+ if ( RESERVED_USERNAMES.has( username ) ) {
290
+ problems.push( `user '${ username }' cannot be stored — '${ username }' is a reserved name that collides with JavaScript's object model, not a typo` );
291
+ return;
292
+ }
293
+ if ( !email ) {
294
+ problems.push( `user '${ username }' has no email, which is the field an application resolves identity by` );
295
+ return;
296
+ }
297
+ if ( !passwordHash || !decodeHash( passwordHash ) ) {
298
+ problems.push( `user '${ username }' has no usable passwordHash — generate one with \`npm run hash-password -w @ti-engine/web-framework\`` );
299
+ return;
300
+ }
301
+ // Usernames are matched exactly, so a repeat is a genuine duplicate. Keyed storage would silently keep the
302
+ // last one and leave the operator unable to tell which password is live, so it is reported instead.
303
+ //
304
+ // A duplicate *email* is deliberately not checked here, unlike a duplicate username: two credentials
305
+ // sharing an email still resolve deterministically to the same person, so there is no "which password
306
+ // is live" ambiguity the way there is for a repeated username. This asymmetry is intentional, not an
307
+ // oversight.
308
+ if ( seen.has( username ) ) {
309
+ problems.push( `duplicate username '${ username }' at entry ${ index } — ignored, the first occurrence is kept` );
310
+ return;
311
+ }
312
+ seen.add( username );
313
+
314
+ records.push( {
315
+ userID: ( typeof entry.userID === "string" && entry.userID.trim() ) || `local:${ username }`,
316
+ username: username,
317
+ email: email,
318
+ name: ( typeof entry.name === "string" && entry.name.trim() ) || username,
319
+ passwordHash: passwordHash,
320
+ disabled: entry.disabled === true
321
+ } );
322
+ } );
323
+
324
+ return { records: records, problems: problems };
325
+ }
326
+
327
+ /**
328
+ * Reads the whole stored directory, or an empty object when it has never been written.
329
+ * <br/>
330
+ * `cache.instance.getJSON` queries the `$` root, and RedisJSON's JSONPath contract wraps that result in a
331
+ * single-element array on a hit — mirrored deliberately by the in-memory test double (see its own doc comment)
332
+ * and already unwrapped once in this package by `ConfigStore#readJSON`. The same unwrap happens here, otherwise
333
+ * every read after the first write would misread a populated directory as empty.
334
+ * <br/>
335
+ * A genuine Redis failure is deliberately left to propagate rather than caught here: swallowing it into `{}`
336
+ * would make an outage indistinguishable from "no users configured", silently failing every local login as
337
+ * "no such user" instead of surfacing the outage to the caller.
338
+ *
339
+ * @returns {Promise<Object>}
340
+ */
341
+ function readStored() {
342
+ return cache.instance.getJSON( CACHE_KEY ).then( ( result ) => {
343
+ const stored = Array.isArray( result ) ? ( result[ 0 ] ?? null ) : ( result ?? null );
344
+ return ( stored && typeof stored === "object" && !Array.isArray( stored ) ) ? stored : {};
345
+ } );
346
+ }
347
+
348
+ /**
349
+ * Writes the records as the complete directory, keyed by username, and reports what changed.
350
+ * <br/>
351
+ * The whole set is written rather than patched because the file is the source of truth: a username absent from
352
+ * `records` must disappear, which is what makes revocation-by-file-edit work. `@ti-engine/core/cache` exposes no
353
+ * delete, so a whole-object write is also the only way to remove a key.
354
+ * <br/>
355
+ * Usernames are attacker-influenceable (the local sign-in handler resolves them from request input), so both the write
356
+ * and every read below are guarded against `Object.prototype`'s reserved names rather than trusting plain bracket
357
+ * access:
358
+ * <br/>
359
+ * - `incoming` is built with a null prototype (`Object.create( null )`) so it inherits nothing. On an ordinary
360
+ * `{}`, `incoming[ "__proto__" ] = record` would not create an own key at all — it would invoke the inherited
361
+ * `__proto__` setter and silently repoint the object's own prototype to `record`, so the record never shows up
362
+ * in `Object.keys`/`JSON.stringify` and is never persisted, without error. On a null-prototype object that
363
+ * setter does not exist anywhere on the (empty) prototype chain, so the assignment falls back to creating a
364
+ * perfectly ordinary own data property instead — confirmed empirically (see the test file) that this still
365
+ * `JSON.stringify`s and round-trips normally.
366
+ * - Every classification read below checks ownership with `Object.prototype.hasOwnProperty.call(...)` rather than
367
+ * relying on truthiness, because `stored` comes back from `readStored()` — ultimately a `JSON.parse` result —
368
+ * with the ordinary `Object.prototype` chain. An unguarded `stored[ "constructor" ]` would resolve to the
369
+ * inherited `Object` constructor function (always truthy) rather than "not present", misclassifying a
370
+ * first-time `constructor`-named user as `updated` instead of `added`, and hiding its removal from `removed`.
371
+ *
372
+ * @method
373
+ * @param {LocalUserRecord[]} records
374
+ * @returns {Promise<{added: string[], updated: string[], removed: string[]}>}
375
+ * @public
376
+ */
377
+ function reconcile( records ) {
378
+ const incoming = Object.create( null );
379
+ ( Array.isArray( records ) ? records : [] ).forEach( ( record ) => {
380
+ incoming[ record.username ] = record;
381
+ } );
382
+
383
+ return readStored().then( ( stored ) => {
384
+ const added = [];
385
+ const updated = [];
386
+ Object.keys( incoming ).forEach( ( username ) => {
387
+ // Ownership check, not truthiness — see the function doc comment: `stored[ username ]` alone would
388
+ // resolve a username of 'constructor' to the inherited Object constructor instead of "not present".
389
+ if ( !Object.prototype.hasOwnProperty.call( stored, username ) ) {
390
+ added.push( username );
391
+ } else if ( JSON.stringify( stored[ username ] ) !== JSON.stringify( incoming[ username ] ) ) {
392
+ updated.push( username );
393
+ }
394
+ } );
395
+ // Same reasoning in the other direction: `incoming` is null-prototype, so this is already safe, but the
396
+ // explicit ownership check keeps both classification directions visibly consistent for the same reason.
397
+ const removed = Object.keys( stored ).filter( ( username ) => !Object.prototype.hasOwnProperty.call( incoming, username ) );
398
+
399
+ return cache.instance.setJSON( CACHE_KEY, incoming ).then( () => {
400
+ return { added: added, updated: updated, removed: removed };
401
+ } );
402
+ } );
403
+ }
404
+
405
+ /**
406
+ * Looks a user up by exact username.
407
+ * <br/>
408
+ * `username` here is attacker-influenceable — this is the function the local sign-in handler calls with the
409
+ * value a client typed into the username field. Checked with `Object.prototype.hasOwnProperty.call(...)` rather than
410
+ * `stored[ username ] || null`, because `stored` carries the ordinary `Object.prototype` chain and an unguarded
411
+ * bracket read would resolve `findByUsername( "constructor" )` to the inherited `Object` constructor function
412
+ * instead of `null`, violating the declared return type for nearly every real query.
413
+ *
414
+ * @method
415
+ * @param {string} username
416
+ * @returns {Promise<LocalUserRecord|null>}
417
+ * @public
418
+ */
419
+ function findByUsername( username ) {
420
+ if ( typeof username !== "string" || username.length === 0 ) {
421
+ return Promise.resolve( null );
422
+ }
423
+ return readStored().then( ( stored ) => {
424
+ return Object.prototype.hasOwnProperty.call( stored, username ) ? stored[ username ] : null;
425
+ } );
426
+ }
427
+
428
+ module.exports = { ALGORITHM, CACHE_KEY, HASH_DEFAULTS, hashPassword, verifyPassword, parseRecords, reconcile, findByUsername };
@@ -15,7 +15,7 @@ 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, the admin allowlist, the trusted request origins, and the `/static` cache policy without editing config files. Note `TI_WEB_AUTH_METHODS`,
18
+ * methods, the admin allowlist, the local auth users file path, the trusted request origins, and the `/static` cache policy without editing config files. Note `TI_WEB_AUTH_METHODS`,
19
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
  *
@@ -59,6 +59,11 @@ function applyWebConfigEnvOverrides( config, env = process.env ) {
59
59
  config.auth = config.auth || {};
60
60
  config.auth.admins = env.TI_WEB_AUTH_ADMINS.split( "," ).map( ( entry ) => entry.trim() ).filter( ( entry ) => entry.length > 0 );
61
61
  }
62
+ if ( env.TI_WEB_AUTH_LOCAL_USERS_PATH !== undefined ) {
63
+ config.auth = config.auth || {};
64
+ config.auth.local = config.auth.local || {};
65
+ config.auth.local.usersPath = env.TI_WEB_AUTH_LOCAL_USERS_PATH;
66
+ }
62
67
  if ( env.TI_WEB_TRUSTED_ORIGINS !== undefined ) {
63
68
  config.trustedOrigins = env.TI_WEB_TRUSTED_ORIGINS.split( "," ).map( ( origin ) => origin.trim() ).filter( ( origin ) => origin.length > 0 );
64
69
  }
@@ -172,7 +172,13 @@ let regenerateAndSaveSession = ( request, redirectTo, modifier ) => {
172
172
  request.session = modifier( request.session );
173
173
  }
174
174
  } catch ( error ) {
175
- reject( error );
175
+ // The application's augment hook refused this login. `session.user` was already assigned in place
176
+ // before the hook ran, and `verifySession` only checks that it exists — so a merely-rejected
177
+ // session would still be persisted by express-session at response end and would admit the user.
178
+ // Destroy it before rejecting so a refusal is genuinely fail-closed.
179
+ request.session.destroy( () => {
180
+ reject( error );
181
+ } );
176
182
  return;
177
183
  }
178
184
  request.session.save( ( error ) => {
@@ -528,7 +534,9 @@ module.exports.defaultErrorHandler = () => {
528
534
  } )
529
535
  } );
530
536
  return response.status( status ).send( "" );
531
- } else if ( isAcceptingResponseType( request, "html" ) && request.method === "GET" ) {
537
+ // A 401 on an HTML request means "you are not signed in" whatever the method the useful answer is the
538
+ // sign-in page carrying the reason, so local auth's POST presents exactly like the OAuth callback's GET.
539
+ } else if ( isAcceptingResponseType( request, "html" ) && ( request.method === "GET" || status === exceptions.httpCode.C_401 ) ) {
532
540
  response.redirect( exceptions.httpCode.C_303, "/?error=" + encodeURIComponent( exception.code ) );
533
541
  } else {
534
542
  response.status( status ).send( payload );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ti-engine/web-framework",
3
- "version": "1.20.1",
3
+ "version": "1.23.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",
@@ -27,6 +27,10 @@
27
27
  "types": "./types/bin/web-server.d.ts",
28
28
  "default": "./bin/web-server.js"
29
29
  },
30
+ "./authorization": {
31
+ "types": "./types/components/authorization.d.ts",
32
+ "default": "./components/authorization.js"
33
+ },
30
34
  "./definitions": {
31
35
  "types": "./types/components/definitions.types.d.ts",
32
36
  "default": "./components/definitions.types.js"
@@ -37,6 +41,10 @@
37
41
  "types": "./types/components/admin-config-handlers.d.ts",
38
42
  "default": "./components/admin-config-handlers.js"
39
43
  },
44
+ "#application-info": {
45
+ "types": "./types/components/application-info.d.ts",
46
+ "default": "./components/application-info.js"
47
+ },
40
48
  "#auth-manager": {
41
49
  "types": "./types/components/auth-manager.d.ts",
42
50
  "default": "./components/auth-manager.js"
@@ -65,6 +73,10 @@
65
73
  "types": "./types/components/definitions.types.d.ts",
66
74
  "default": "./components/definitions.types.js"
67
75
  },
76
+ "#local-user-directory": {
77
+ "types": "./types/components/local-user-directory.d.ts",
78
+ "default": "./components/local-user-directory.js"
79
+ },
68
80
  "#session-store": {
69
81
  "types": "./types/components/session-store.d.ts",
70
82
  "default": "./components/session-store.js"
@@ -134,6 +146,7 @@
134
146
  "scripts": {
135
147
  "postinstall": "node ./bin/build/post-install.js",
136
148
  "test": "node --test test/*.test.js",
137
- "build:types": "tsc -p tsconfig.types.json"
149
+ "build:types": "tsc -p tsconfig.types.json",
150
+ "hash-password": "node ./bin/build/hash-password.js"
138
151
  }
139
152
  }
@@ -1,5 +1,5 @@
1
1
  export = TiWebAppManager;
2
- import type { TiSession } from "#definitions";
2
+ import type { TiApplicationInfo, TiInfoSection, TiProfileInfo, TiSession } from "#definitions";
3
3
  /**
4
4
  * Gates the login-page authentication markup to the effective enabled methods. The login fragment delimits blocks
5
5
  * with HTML-comment markers: `<!--ti-auth-method:METHOD-->…<!--/ti-auth-method-->` around each method's control
@@ -160,6 +160,100 @@ declare class TiWebAppManager {
160
160
  * @public
161
161
  */
162
162
  processDataRequest(session: TiSession, view: string, options?: Object): Promise<Object>;
163
+ /**
164
+ * Returns the configuration for the shared UI components the application shell renders — currently the sidebar
165
+ * user flyout menu. Shipped as part of the `config` data payload and merged into the `tiComponentsConfig`
166
+ * Alpine store on the client.
167
+ * <br/>
168
+ * The default menu links the two screens the framework itself provides (Profile and About) plus sign-out, so a
169
+ * consuming application gets a working user menu without configuring one. Override to replace it; a subclass
170
+ * that supplies its own `componentsConfig` naturally supersedes this.
171
+ *
172
+ * @method
173
+ * @param {TiSession} session
174
+ * @returns {Object}
175
+ * @virtual
176
+ * @public
177
+ */
178
+ buildComponentsConfig(session: TiSession): Object;
179
+ /**
180
+ * Returns the descriptor rendered by the "Profile" screen — the identity header plus an ordered list of titled
181
+ * label/value sections. Every string in it is display-ready: the server resolves labels and formats values,
182
+ * because this is where the session language and the label catalogue are (see {@link resolveLabel}).
183
+ * <br/>
184
+ * The default implementation reports what the framework itself knows about the session user — name, username,
185
+ * e-mail, language and roles. Override in subclasses to show application-owned data instead; the screen, its
186
+ * Alpine component and its styling are inherited unchanged, so an override only decides the content.
187
+ * <br/>
188
+ * NOTE: The descriptor is always about the SESSION user. There is deliberately no "whose profile" parameter —
189
+ * viewing another person's record belongs to an application screen that carries its own scoping rules.
190
+ *
191
+ * @method
192
+ * @param {TiSession} session
193
+ * @returns {Promise<TiProfileInfo>}
194
+ * @exception {TiException.E_SEC_UNAUTHORIZED_ACCESS} (401) When the session carries no user.
195
+ * @virtual
196
+ * @public
197
+ */
198
+ getProfileInfo(session: TiSession): Promise<TiProfileInfo>;
199
+ /**
200
+ * Returns the descriptor rendered by the "About" screen — the application's own identity (name, version,
201
+ * release date, description, license, homepage) plus the ti-engine component versions it runs on, and any
202
+ * extra sections the application contributes.
203
+ * <br/>
204
+ * The baseline is resolved once from the consuming application's `package.json`, overridable through
205
+ * `TI_WEB_APP_NAME` / `TI_WEB_APP_VERSION` / `TI_WEB_APP_RELEASE_DATE` (see `#application-info`), and cached —
206
+ * the manifest cannot change while the process runs.
207
+ * <br/>
208
+ * NOTE: Runtime facts (node version, platform, instance identity) are attached only for an `admin` session.
209
+ * They are operational detail that helps support and means nothing to an ordinary user, so they are not handed
210
+ * to every signed-in visitor. Override in subclasses to append application-specific sections; call `super` and
211
+ * extend the result rather than rebuilding it.
212
+ *
213
+ * @method
214
+ * @param {TiSession} session
215
+ * @returns {Promise<TiApplicationInfo>}
216
+ * @virtual
217
+ * @public
218
+ */
219
+ getApplicationInfo(session: TiSession): Promise<TiApplicationInfo>;
220
+ /**
221
+ * Builds the identity header of the Profile screen from the session user. Kept separate from
222
+ * {@link TiWebAppManager#getProfileInfo} so a subclass that replaces the sections can still reuse — or fall
223
+ * back to — the framework's identity block when the application has no richer identity to show.
224
+ *
225
+ * @method
226
+ * @param {TiSession} session
227
+ * @returns {Object}
228
+ * @public
229
+ */
230
+ buildSessionIdentity(session: TiSession): Object;
231
+ /**
232
+ * Builds the framework's account-level Profile sections from the session user. A subclass showing richer
233
+ * application data can append these so the account facts remain visible alongside it.
234
+ *
235
+ * @method
236
+ * @param {TiSession} session
237
+ * @returns {TiInfoSection[]}
238
+ * @public
239
+ */
240
+ buildAccountSections(session: TiSession): TiInfoSection[];
241
+ /**
242
+ * Resolves the versions of the ti-engine packages the running application is built on, for the About screen.
243
+ * <br/>
244
+ * NOTE: `@ti-engine/core` does not expose `./package.json` through its exports map, so its manifest is located
245
+ * by walking up from a module it *does* export. A package that cannot be resolved is simply omitted — an
246
+ * informational screen must never be the reason a request fails.
247
+ *
248
+ * @method
249
+ * @static
250
+ * @returns {Array<{name: string, version: string}>}
251
+ * @public
252
+ */
253
+ static resolveFrameworkComponents(): Array<{
254
+ name: string;
255
+ version: string;
256
+ }>;
163
257
  /**
164
258
  * Used to process an application service request.
165
259
  *