@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.1",
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.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
  }
@@ -46,7 +46,14 @@ export function applyApiMethods(KernelClass: { prototype: Kernel }): void {
46
46
  error: axiosError,
47
47
  })
48
48
 
49
- if (status === 401 || status === 403) {
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!,
@@ -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)