@quazardous/qdadm 2.17.2 → 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.2",
3
+ "version": "2.17.3",
4
4
  "description": "Vue 3 framework for admin dashboards with PrimeVue",
5
5
  "author": "quazardous",
6
6
  "license": "MIT",
@@ -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
  }
@@ -34,6 +34,12 @@ export function applyRegistryMethods(KernelClass: { prototype: Kernel }): void {
34
34
  * mounted on the same Vue app.
35
35
  */
36
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
+
37
43
  if (this.options.existingSignals) {
38
44
  this.signals = this.options.existingSignals
39
45
  return
@@ -84,6 +84,15 @@ export class Kernel {
84
84
  options: KernelOptions
85
85
  vueApp: App | null = null
86
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
+ */
87
96
  signals: SignalBus | null = null
88
97
  orchestrator: Orchestrator | null = null
89
98
  zoneRegistry: ZoneRegistry | null = null
@@ -100,6 +109,8 @@ export class Kernel {
100
109
  activeStack: ActiveStack | null = null
101
110
  stackHydrator: StackHydrator | null = null
102
111
  notificationStore: NotificationStore | null = null
112
+ /** One auth:expired per session — see Kernel.auth.ts (#1905). */
113
+ _expiredHandled = false
103
114
  i18nInstance: I18n | null = null
104
115
 
105
116
  /** Pending provides from modules (applied after vueApp creation) */
@@ -149,6 +160,12 @@ export class Kernel {
149
160
  }
150
161
 
151
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()
152
169
  }
153
170
 
154
171
  /**