@quazardous/qdadm 2.17.1 → 2.17.3
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@quazardous/qdadm",
|
|
3
|
-
"version": "2.17.
|
|
3
|
+
"version": "2.17.3",
|
|
4
4
|
"description": "Vue 3 framework for admin dashboards with PrimeVue",
|
|
5
5
|
"author": "quazardous",
|
|
6
6
|
"license": "MIT",
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"dist/node"
|
|
55
55
|
],
|
|
56
56
|
"dependencies": {
|
|
57
|
-
"@quazardous/qdcore": "^1.1.
|
|
57
|
+
"@quazardous/qdcore": "^1.1.2",
|
|
58
58
|
"@quazardous/qddebug": "^1.1.0",
|
|
59
59
|
"@quazardous/quarkernel": "^2.1.0",
|
|
60
60
|
"yaml": "^2.8.4"
|
|
@@ -357,6 +357,61 @@ export class EntityManager<T extends EntityRecord = EntityRecord> {
|
|
|
357
357
|
return { storage: this.storage }
|
|
358
358
|
}
|
|
359
359
|
|
|
360
|
+
/**
|
|
361
|
+
* Announce an expired session, whatever the transport (#1905 lot A).
|
|
362
|
+
*
|
|
363
|
+
* `auth:expired` used to be emitted only by the kernel's axios response
|
|
364
|
+
* interceptor. But `SdkStorage` exists so a consumer can bring their own
|
|
365
|
+
* transport, and one that did — a fetch-based SDK — never triggered expiry
|
|
366
|
+
* detection at all: the screen kept requesting with a dead token and stacked
|
|
367
|
+
* one error toast per attempt, indefinitely.
|
|
368
|
+
*
|
|
369
|
+
* Watching here rather than in a storage adapter is deliberate. Storages
|
|
370
|
+
* have no access to the signal bus, and every call passes through this
|
|
371
|
+
* manager whichever adapter is underneath — so this covers ApiStorage,
|
|
372
|
+
* MockApiStorage, SdkStorage and whatever someone writes next.
|
|
373
|
+
*
|
|
374
|
+
* 401 only, never 403: a 403 says the session is valid and this door is
|
|
375
|
+
* closed. Logging the user out would send them to sign in again for a
|
|
376
|
+
* permission they will not have afterwards — a loop, not an error.
|
|
377
|
+
*/
|
|
378
|
+
protected _watchStorageAuth(storage: IStorage<T> | null): IStorage<T> | null {
|
|
379
|
+
if (!storage || !this._signals) return storage
|
|
380
|
+
|
|
381
|
+
const emitExpired = (error: unknown): void => {
|
|
382
|
+
const status = (error as { status?: number })?.status
|
|
383
|
+
if (status !== 401) return
|
|
384
|
+
this._signals?.emit('auth:expired', { status, entity: this.name })
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
return new Proxy(storage as IStorage<T> & object, {
|
|
388
|
+
get(target, prop, receiver) {
|
|
389
|
+
const value = Reflect.get(target, prop, receiver)
|
|
390
|
+
if (typeof value !== 'function') return value
|
|
391
|
+
|
|
392
|
+
return function (this: unknown, ...args: unknown[]) {
|
|
393
|
+
try {
|
|
394
|
+
const result = (value as (...a: unknown[]) => unknown).apply(
|
|
395
|
+
this === receiver ? target : this,
|
|
396
|
+
args
|
|
397
|
+
)
|
|
398
|
+
// Storage methods are async; a rejection is the normal error path.
|
|
399
|
+
if (result instanceof Promise) {
|
|
400
|
+
return result.catch((error: unknown) => {
|
|
401
|
+
emitExpired(error)
|
|
402
|
+
throw error
|
|
403
|
+
})
|
|
404
|
+
}
|
|
405
|
+
return result
|
|
406
|
+
} catch (error) {
|
|
407
|
+
emitExpired(error)
|
|
408
|
+
throw error
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
},
|
|
412
|
+
})
|
|
413
|
+
}
|
|
414
|
+
|
|
360
415
|
/**
|
|
361
416
|
* Normalize resolveStorage() return value to standard format
|
|
362
417
|
*/
|
|
@@ -367,7 +422,7 @@ export class EntityManager<T extends EntityRecord = EntityRecord> {
|
|
|
367
422
|
// Function = dynamic endpoint builder
|
|
368
423
|
if (typeof result === 'function') {
|
|
369
424
|
return {
|
|
370
|
-
storage: this.storage,
|
|
425
|
+
storage: this._watchStorageAuth(this.storage),
|
|
371
426
|
endpoint: result(context),
|
|
372
427
|
isDynamic: true,
|
|
373
428
|
}
|
|
@@ -375,18 +430,18 @@ export class EntityManager<T extends EntityRecord = EntityRecord> {
|
|
|
375
430
|
|
|
376
431
|
// String = endpoint with primary storage
|
|
377
432
|
if (typeof result === 'string') {
|
|
378
|
-
return { storage: this.storage, endpoint: result }
|
|
433
|
+
return { storage: this._watchStorageAuth(this.storage), endpoint: result }
|
|
379
434
|
}
|
|
380
435
|
|
|
381
436
|
// Null/undefined = primary storage
|
|
382
437
|
if (!result) {
|
|
383
|
-
return { storage: this.storage }
|
|
438
|
+
return { storage: this._watchStorageAuth(this.storage) }
|
|
384
439
|
}
|
|
385
440
|
|
|
386
441
|
// Object with endpoint function = dynamic endpoint builder
|
|
387
442
|
if (typeof result.endpoint === 'function') {
|
|
388
443
|
return {
|
|
389
|
-
storage: result.storage || this.storage,
|
|
444
|
+
storage: this._watchStorageAuth(result.storage || this.storage),
|
|
390
445
|
endpoint: result.endpoint(context),
|
|
391
446
|
params: result.params,
|
|
392
447
|
isDynamic: true,
|
|
@@ -396,14 +451,14 @@ export class EntityManager<T extends EntityRecord = EntityRecord> {
|
|
|
396
451
|
// Object without storage = use primary storage
|
|
397
452
|
if (!result.storage) {
|
|
398
453
|
return {
|
|
399
|
-
storage: this.storage,
|
|
454
|
+
storage: this._watchStorageAuth(this.storage),
|
|
400
455
|
endpoint: result.endpoint as string | undefined,
|
|
401
456
|
params: result.params,
|
|
402
457
|
}
|
|
403
458
|
}
|
|
404
459
|
|
|
405
460
|
return {
|
|
406
|
-
storage: result.storage,
|
|
461
|
+
storage: this._watchStorageAuth(result.storage ?? this.storage),
|
|
407
462
|
endpoint: result.endpoint as string | undefined,
|
|
408
463
|
params: result.params,
|
|
409
464
|
}
|
package/src/kernel/Kernel.api.ts
CHANGED
|
@@ -46,7 +46,14 @@ export function applyApiMethods(KernelClass: { prototype: Kernel }): void {
|
|
|
46
46
|
error: axiosError,
|
|
47
47
|
})
|
|
48
48
|
|
|
49
|
-
|
|
49
|
+
// 401 only, never 403 (#1905 lot F).
|
|
50
|
+
//
|
|
51
|
+
// A 403 says the session is valid and this door is closed. Logging the
|
|
52
|
+
// user out sends them to sign in again for a permission they will not
|
|
53
|
+
// have any more afterwards — a loop, not an error. The 403 still
|
|
54
|
+
// travels as api:error above, which is where a permission refusal
|
|
55
|
+
// belongs.
|
|
56
|
+
if (status === 401) {
|
|
50
57
|
if (debug) {
|
|
51
58
|
console.warn(
|
|
52
59
|
`[Kernel] API ${status} error on ${url}, emitting auth:expired`
|
|
@@ -51,8 +51,34 @@ export function applyAuthMethods(KernelClass: { prototype: Kernel }): void {
|
|
|
51
51
|
const { authAdapter, onAuthExpired } = this.options
|
|
52
52
|
if (!authAdapter) return
|
|
53
53
|
|
|
54
|
+
// A new session re-arms the guard below. Registered before the handler so
|
|
55
|
+
// the ordering is obvious to whoever reads this next.
|
|
56
|
+
this.signals!.on('auth:login', () => {
|
|
57
|
+
this._expiredHandled = false
|
|
58
|
+
})
|
|
59
|
+
|
|
54
60
|
this.signals!.on('auth:expired', async (payload: { data: unknown }) => {
|
|
55
61
|
const debug = this.options.debug ?? false
|
|
62
|
+
|
|
63
|
+
// One expiry per session (#1905 lot E).
|
|
64
|
+
//
|
|
65
|
+
// The handler logs out, emits auth:logout — which _setupAuthInvalidation
|
|
66
|
+
// turns into a full invalidateApp() — and redirects. Without this guard
|
|
67
|
+
// a page firing four requests that all 401 remounts the app four times
|
|
68
|
+
// while navigating to the login screen.
|
|
69
|
+
//
|
|
70
|
+
// Guarded on OUR OWN state, deliberately: an earlier draft tested
|
|
71
|
+
// `authAdapter.isAuthenticated()`, which would have skipped the very
|
|
72
|
+
// first signal for any consumer that clears its auth state before
|
|
73
|
+
// emitting — turning this fix into the silent no-op it exists to remove.
|
|
74
|
+
if (this._expiredHandled) {
|
|
75
|
+
if (debug) {
|
|
76
|
+
console.debug('[Kernel] auth:expired already handled for this session, ignoring')
|
|
77
|
+
}
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
this._expiredHandled = true
|
|
81
|
+
|
|
56
82
|
if (debug) {
|
|
57
83
|
console.warn('[Kernel] auth:expired received:', payload)
|
|
58
84
|
}
|
|
@@ -15,6 +15,7 @@ import { StackHydrator } from '../chain/StackHydrator.js'
|
|
|
15
15
|
import { Orchestrator } from '../orchestrator/Orchestrator'
|
|
16
16
|
import type { EntityAuthAdapter } from '../entity/auth/EntityAuthAdapter'
|
|
17
17
|
import type { Kernel } from './Kernel'
|
|
18
|
+
import type { SSEConfig } from './Kernel.types'
|
|
18
19
|
// #1196 Phase B — this-typing against the real Kernel shape (was Self = any)
|
|
19
20
|
type Self = Kernel
|
|
20
21
|
|
|
@@ -33,6 +34,12 @@ export function applyRegistryMethods(KernelClass: { prototype: Kernel }): void {
|
|
|
33
34
|
* mounted on the same Vue app.
|
|
34
35
|
*/
|
|
35
36
|
proto._createSignalBus = function (this: Self): void {
|
|
37
|
+
// Idempotent (#1905 lot B): the constructor builds the bus so consumers can
|
|
38
|
+
// wire it immediately. Replacing it here would orphan every listener
|
|
39
|
+
// registered between construction and createApp() — silently, which is
|
|
40
|
+
// exactly the failure mode this lot exists to remove.
|
|
41
|
+
if (this.signals) return
|
|
42
|
+
|
|
36
43
|
if (this.options.existingSignals) {
|
|
37
44
|
this.signals = this.options.existingSignals
|
|
38
45
|
return
|
|
@@ -217,12 +224,72 @@ export function applyRegistryMethods(KernelClass: { prototype: Kernel }): void {
|
|
|
217
224
|
/**
|
|
218
225
|
* Create SSEBridge for Server-Sent Events to SignalBus integration
|
|
219
226
|
*/
|
|
227
|
+
/**
|
|
228
|
+
* Warn about unrecognised `sse` keys (#1898 lot A).
|
|
229
|
+
*
|
|
230
|
+
* A consumer configured `sse.getToken` against a version that predated it.
|
|
231
|
+
* It was not rejected — it was ignored, and the bridge fell back to the
|
|
232
|
+
* session's DURABLE token, which then travelled in the query string of every
|
|
233
|
+
* `/events` request and into logs that outlive the session by months. No
|
|
234
|
+
* error, no warning, and a stream that worked perfectly.
|
|
235
|
+
*
|
|
236
|
+
* The warning therefore names what happens INSTEAD, not merely that the key
|
|
237
|
+
* was ignored: "ignored" reads as "no effect", not as "falls back to a more
|
|
238
|
+
* sensitive secret". That reading is what made the failure invisible.
|
|
239
|
+
*/
|
|
240
|
+
proto._validateSseConfig = function (this: Self, sse: SSEConfig): void {
|
|
241
|
+
const known = new Set([
|
|
242
|
+
'url',
|
|
243
|
+
'reconnectDelay',
|
|
244
|
+
'signalPrefix',
|
|
245
|
+
'autoConnect',
|
|
246
|
+
'withCredentials',
|
|
247
|
+
'tokenParam',
|
|
248
|
+
'events',
|
|
249
|
+
'entities',
|
|
250
|
+
'getToken',
|
|
251
|
+
'connectOnSignal',
|
|
252
|
+
'disconnectOnSignal',
|
|
253
|
+
])
|
|
254
|
+
|
|
255
|
+
const unknown = Object.keys(sse).filter((key) => !known.has(key))
|
|
256
|
+
if (!unknown.length) return
|
|
257
|
+
|
|
258
|
+
// What the app loses by having the key ignored, wherever we can say it.
|
|
259
|
+
const consequences: Record<string, string> = {
|
|
260
|
+
getToken:
|
|
261
|
+
'the session auth token will be sent in the stream URL instead — ' +
|
|
262
|
+
'and query strings reach access logs',
|
|
263
|
+
entities: 'no entity cache will be invalidated from the stream',
|
|
264
|
+
tokenParam: 'the token will be sent under the default name "token"',
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
for (const key of unknown) {
|
|
268
|
+
const near = [...known].find(
|
|
269
|
+
(k) => k.toLowerCase() === key.toLowerCase() || k.startsWith(key) || key.startsWith(k)
|
|
270
|
+
)
|
|
271
|
+
// Look the consequence up under the NEAREST KNOWN key: a typo like
|
|
272
|
+
// `getTokens` means `getToken` is absent, and it is that absence whose
|
|
273
|
+
// effect the reader needs to hear.
|
|
274
|
+
const consequence = consequences[key] ?? (near ? consequences[near] : undefined)
|
|
275
|
+
const because = consequence ? ` — ${consequence}` : ''
|
|
276
|
+
const suggestion = near ? ` Did you mean "${near}"?` : ''
|
|
277
|
+
console.warn(
|
|
278
|
+
`[Kernel] sse.${key} is not a recognised option and is IGNORED${because}.` +
|
|
279
|
+
`${suggestion} This usually means the installed qdadm predates the option ` +
|
|
280
|
+
`— check the version before assuming the key has no effect.`
|
|
281
|
+
)
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
220
285
|
proto._createSSEBridge = function (this: Self): void {
|
|
221
286
|
const { sse, authAdapter } = this.options
|
|
222
287
|
if (!sse?.url) return
|
|
223
288
|
|
|
224
289
|
const debug = this.options.debug ?? false
|
|
225
290
|
|
|
291
|
+
this._validateSseConfig(sse)
|
|
292
|
+
|
|
226
293
|
// An explicit sse.getToken wins — including an explicit null, which means
|
|
227
294
|
// "send no token" and must not fall back to the auth adapter.
|
|
228
295
|
const getToken =
|
|
@@ -259,6 +326,29 @@ export function applyRegistryMethods(KernelClass: { prototype: Kernel }): void {
|
|
|
259
326
|
})
|
|
260
327
|
}
|
|
261
328
|
|
|
329
|
+
// A restored session must connect the stream too (#1898 lot C).
|
|
330
|
+
//
|
|
331
|
+
// The bridge waits for `auth:login`, which is emitted by the LOGIN PAGE —
|
|
332
|
+
// by the interface, not by the authentication state. So reloading a page
|
|
333
|
+
// with a valid session, or revalidating one without going through our
|
|
334
|
+
// login form, left the stream permanently disconnected.
|
|
335
|
+
//
|
|
336
|
+
// Deliberately NOT fixed by emitting `auth:login` at boot: the kernel
|
|
337
|
+
// listens to it in _setupAuthInvalidation and remounts the whole app, so
|
|
338
|
+
// announcing a restored session that way would remount on every reload.
|
|
339
|
+
// Connecting the bridge directly says the same thing without the blast
|
|
340
|
+
// radius.
|
|
341
|
+
// `in`, not `??`: an EXPLICIT null means "I drive the connection myself",
|
|
342
|
+
// and `??` would treat it as absent and override the opt-out.
|
|
343
|
+
const connectsOnSignal =
|
|
344
|
+
('connectOnSignal' in sse ? sse.connectOnSignal : 'auth:login') !== null
|
|
345
|
+
if (!sse.autoConnect && connectsOnSignal && authAdapter?.isAuthenticated?.()) {
|
|
346
|
+
if (debug) {
|
|
347
|
+
console.debug('[Kernel] session already authenticated → connecting SSE')
|
|
348
|
+
}
|
|
349
|
+
void this.sseBridge.connect()
|
|
350
|
+
}
|
|
351
|
+
|
|
262
352
|
if (sse.entities) {
|
|
263
353
|
this.liveEntityRouter = createLiveEntityRouter({
|
|
264
354
|
signals: this.signals!,
|
package/src/kernel/Kernel.ts
CHANGED
|
@@ -51,6 +51,7 @@ import type {
|
|
|
51
51
|
KernelOptions,
|
|
52
52
|
InternalLayoutComponents,
|
|
53
53
|
AxiosLikeClient,
|
|
54
|
+
SSEConfig as SSEConfigType,
|
|
54
55
|
} from './Kernel.types'
|
|
55
56
|
|
|
56
57
|
// Re-export public types
|
|
@@ -83,6 +84,15 @@ export class Kernel {
|
|
|
83
84
|
options: KernelOptions
|
|
84
85
|
vueApp: App | null = null
|
|
85
86
|
router: Router | null = null
|
|
87
|
+
/**
|
|
88
|
+
* Available from construction, not from createApp() (#1905 lot B).
|
|
89
|
+
*
|
|
90
|
+
* It used to be null until createApp(), so wiring `kernel.signals` right
|
|
91
|
+
* after `new Kernel()` — the natural place — silently did nothing:
|
|
92
|
+
* `null?.emit?.()` does not throw. The bus has no dependencies, so the
|
|
93
|
+
* window existed for no reason. Removing it beats warning about it
|
|
94
|
+
* (ADR 0011).
|
|
95
|
+
*/
|
|
86
96
|
signals: SignalBus | null = null
|
|
87
97
|
orchestrator: Orchestrator | null = null
|
|
88
98
|
zoneRegistry: ZoneRegistry | null = null
|
|
@@ -99,6 +109,8 @@ export class Kernel {
|
|
|
99
109
|
activeStack: ActiveStack | null = null
|
|
100
110
|
stackHydrator: StackHydrator | null = null
|
|
101
111
|
notificationStore: NotificationStore | null = null
|
|
112
|
+
/** One auth:expired per session — see Kernel.auth.ts (#1905). */
|
|
113
|
+
_expiredHandled = false
|
|
102
114
|
i18nInstance: I18n | null = null
|
|
103
115
|
|
|
104
116
|
/** Pending provides from modules (applied after vueApp creation) */
|
|
@@ -148,6 +160,12 @@ export class Kernel {
|
|
|
148
160
|
}
|
|
149
161
|
|
|
150
162
|
this.options = options
|
|
163
|
+
|
|
164
|
+
// The bus exists from here on (#1905 lot B). It has no dependencies, and
|
|
165
|
+
// the natural place to wire it is right after `new Kernel()` — so it must
|
|
166
|
+
// be usable there. _createSignalBus() is idempotent, so the later call in
|
|
167
|
+
// createApp() keeps this instance rather than orphaning its listeners.
|
|
168
|
+
this._createSignalBus()
|
|
151
169
|
}
|
|
152
170
|
|
|
153
171
|
/**
|
|
@@ -440,6 +458,7 @@ export interface Kernel {
|
|
|
440
458
|
_createStackHydrator(): void
|
|
441
459
|
_createDeferredRegistry(): void
|
|
442
460
|
_createEventRouter(): void
|
|
461
|
+
_validateSseConfig(sse: SSEConfigType): void
|
|
443
462
|
_createSSEBridge(): void
|
|
444
463
|
|
|
445
464
|
// Vue (Kernel.vue.ts)
|