@quazardous/qdadm 2.18.0 → 2.20.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/package.json +5 -7
- package/src/components/editors/VanillaJsonEditor.vue +14 -4
- package/src/components/lists/ListPage.vue +7 -0
- package/src/composables/index.ts +3 -0
- package/src/composables/useLayoutResolver.ts +10 -0
- package/src/composables/useListPage.filters.ts +137 -67
- package/src/composables/useListPage.ts +148 -26
- package/src/composables/useListPage.types.ts +74 -5
- package/src/composables/useListPage.utils.ts +58 -0
- package/src/entity/EntityManager.interface.ts +11 -0
- package/src/entity/EntityManager.ts +5 -0
- package/src/entity/EntityManager.types.ts +11 -0
- package/src/entity/storage/ApiStorage.ts +16 -2
- package/src/entity/storage/LocalStorage.ts +1 -1
- package/src/entity/storage/MemoryStorage.ts +7 -2
- package/src/entity/storage/MockApiStorage.ts +1 -1
- package/src/entity/storage/SdkStorage.ts +12 -3
- package/src/gen/createManagers.ts +15 -1
- package/src/gen/index.ts +7 -1
- package/src/index.ts +19 -0
- package/src/kernel/Kernel.registries.ts +202 -46
- package/src/kernel/Kernel.ts +34 -9
- package/src/kernel/Kernel.types.ts +19 -0
- package/src/kernel/Kernel.vue.ts +7 -0
- package/src/kernel/KernelContext.routing.ts +18 -7
- package/src/plugin.ts +1 -1
- package/src/query/clientFilter.ts +14 -3
- package/src/routeState/CompositePersister.ts +144 -0
- package/src/routeState/CookiePersister.ts +109 -0
- package/src/routeState/MemoryPersister.ts +60 -0
- package/src/routeState/NullPersister.ts +33 -0
- package/src/routeState/RouteStatePersister.ts +86 -0
- package/src/routeState/UrlPersister.ts +207 -0
- package/src/routeState/WebStoragePersister.ts +149 -0
- package/src/routeState/defaultPersister.ts +66 -0
- package/src/routeState/factory.ts +86 -0
- package/src/routeState/index.ts +32 -0
- package/src/routeState/keyLocator.ts +84 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { IStorage } from './IStorage'
|
|
2
2
|
import type { EntityRecord, ListParams, ListResult, StorageCapabilities } from '../../types'
|
|
3
|
-
import { sortItems, filterItems, paginate } from '../../query/clientFilter'
|
|
3
|
+
import { sortItems, filterItems, paginate, searchItems } from '../../query/clientFilter'
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* SDK method result with data property
|
|
@@ -275,9 +275,13 @@ export class SdkStorage<T extends EntityRecord = EntityRecord> extends IStorage<
|
|
|
275
275
|
}
|
|
276
276
|
|
|
277
277
|
async list(params: ListParams = {}): Promise<ListResult<T>> {
|
|
278
|
-
const { page = 1, page_size = 20, sort_by, sort_order = 'asc', filters = {} } = params
|
|
278
|
+
const { page = 1, page_size = 20, sort_by, sort_order = 'asc', filters = {}, search } = params
|
|
279
279
|
|
|
280
|
-
|
|
280
|
+
// `search` goes to the SDK with everything else (#2147). It was dropped
|
|
281
|
+
// here, so a search box over an SDK-backed entity typed into the void:
|
|
282
|
+
// the call went out without the term and the rows came back unfiltered,
|
|
283
|
+
// with nothing anywhere saying why.
|
|
284
|
+
const queryParams = { query: { page, page_size, sort_by, sort_order, search, ...filters } }
|
|
281
285
|
const data = (await this._execute('list', queryParams)) as ListResult<T>
|
|
282
286
|
|
|
283
287
|
let { items, total } = data
|
|
@@ -292,8 +296,13 @@ export class SdkStorage<T extends EntityRecord = EntityRecord> extends IStorage<
|
|
|
292
296
|
|
|
293
297
|
// Shared pipeline (#1192) — original order preserved: sort, then
|
|
294
298
|
// filter (substring semantics), then paginate.
|
|
299
|
+
//
|
|
300
|
+
// The search is applied here TOO, not only sent (#2147). An SDK that
|
|
301
|
+
// ignores the term would otherwise hand back the whole collection and
|
|
302
|
+
// this branch would paginate it as if it had been searched.
|
|
295
303
|
items = sortItems([...items], sort_by, sort_order)
|
|
296
304
|
items = filterItems(items, filters, { stringMatch: 'includes' })
|
|
305
|
+
items = searchItems(items, search)
|
|
297
306
|
total = items.length
|
|
298
307
|
items = paginate(items, page, page_size)
|
|
299
308
|
}
|
|
@@ -196,7 +196,7 @@ function findSchemaForEntity(
|
|
|
196
196
|
* const users = await usersManager.list()
|
|
197
197
|
* ```
|
|
198
198
|
*/
|
|
199
|
-
export function
|
|
199
|
+
export function createGeneratedManagers(config: CreateManagersConfig): Map<string, EntityManager> {
|
|
200
200
|
// Validate config structure early
|
|
201
201
|
validateConfig(config)
|
|
202
202
|
|
|
@@ -243,3 +243,17 @@ export function createManagers(config: CreateManagersConfig): Map<string, Entity
|
|
|
243
243
|
|
|
244
244
|
return managers
|
|
245
245
|
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* @deprecated Renamed to `createGeneratedManagers` (#1902).
|
|
249
|
+
*
|
|
250
|
+
* The root exports a DIFFERENT `createManagers` — `entity/factory.ts`, which
|
|
251
|
+
* takes `(config, context)` and returns a `Record`, where this one takes a
|
|
252
|
+
* single generated config and returns a `Map`. Same name, two public entry
|
|
253
|
+
* points, different signatures and different return types, with nothing to
|
|
254
|
+
* warn whoever picked the wrong import: the mistake surfaces at the first
|
|
255
|
+
* `.get()`.
|
|
256
|
+
*
|
|
257
|
+
* This alias keeps existing imports working. Use `createGeneratedManagers`.
|
|
258
|
+
*/
|
|
259
|
+
export const createManagers = createGeneratedManagers
|
package/src/gen/index.ts
CHANGED
|
@@ -51,7 +51,13 @@ export { OpenAPIConnector, type OpenAPIConnectorOptions, type OperationFilter }
|
|
|
51
51
|
export { type StorageProfileFactory, type StorageProfileOptions } from './StorageProfileFactory'
|
|
52
52
|
|
|
53
53
|
// T00317: createManagers - Runtime factory for EntityManager creation
|
|
54
|
-
export {
|
|
54
|
+
export {
|
|
55
|
+
createGeneratedManagers,
|
|
56
|
+
/** @deprecated Renamed to `createGeneratedManagers` (#1902) — it collided with the root's own `createManagers`. */
|
|
57
|
+
createManagers,
|
|
58
|
+
type CreateManagersConfig,
|
|
59
|
+
type EntityConfig,
|
|
60
|
+
} from './createManagers'
|
|
55
61
|
|
|
56
62
|
// T00318: Decorators - Per-entity field customization layer
|
|
57
63
|
export { applyDecorators, type FieldDecorator, type EntityDecorator } from './decorators'
|
package/src/index.ts
CHANGED
|
@@ -237,10 +237,13 @@ export * from './composables/useJsonSyntax'
|
|
|
237
237
|
export {
|
|
238
238
|
useListPage,
|
|
239
239
|
PAGE_SIZE_OPTIONS,
|
|
240
|
+
FILTER_TYPES,
|
|
240
241
|
type UseListPageOptions,
|
|
241
242
|
type UseListPageReturn,
|
|
242
243
|
type ColumnConfig,
|
|
243
244
|
type FilterConfig,
|
|
245
|
+
type FilterOptions,
|
|
246
|
+
type FilterType,
|
|
244
247
|
type ActionConfig as ListActionConfig,
|
|
245
248
|
type ResolvedAction,
|
|
246
249
|
type HeaderActionConfig,
|
|
@@ -322,6 +325,22 @@ export {
|
|
|
322
325
|
type LayoutMeta,
|
|
323
326
|
} from './composables/useLayoutResolver'
|
|
324
327
|
export { useSSEBridge, type UseSSEBridgeReturn } from './composables/useSSEBridge'
|
|
328
|
+
|
|
329
|
+
// Route-state persistence (#2146) — where a route remembers what it shows.
|
|
330
|
+
export {
|
|
331
|
+
UrlPersister,
|
|
332
|
+
isRouteStatePersister,
|
|
333
|
+
resolveRouteStatePersister,
|
|
334
|
+
createRouteStatePersisterFactory,
|
|
335
|
+
type RouteStatePersister,
|
|
336
|
+
type RouteStateScope,
|
|
337
|
+
type RouteStatePersisterSpec,
|
|
338
|
+
type RouteStatePersisterFactory,
|
|
339
|
+
type UrlPersisterOptions,
|
|
340
|
+
dottedKeyLocator,
|
|
341
|
+
namespacedKeyLocator,
|
|
342
|
+
type RouteStateKeyLocator,
|
|
343
|
+
} from './routeState'
|
|
325
344
|
export {
|
|
326
345
|
useDeferred,
|
|
327
346
|
useDeferredValue,
|
|
@@ -52,9 +52,14 @@ export function applyRegistryMethods(KernelClass: { prototype: Kernel }): void {
|
|
|
52
52
|
* Create hook registry for Drupal-inspired extensibility
|
|
53
53
|
*/
|
|
54
54
|
proto._createHookRegistry = function (this: Self): void {
|
|
55
|
+
// Idempotent, and built in the constructor (#1906 lot B1): replacing it in
|
|
56
|
+
// createApp() would orphan every hook registered before then — silently,
|
|
57
|
+
// which is the failure this lot exists to remove.
|
|
58
|
+
if (this.hookRegistry) return
|
|
59
|
+
|
|
55
60
|
const debug = this.options.debug ?? false
|
|
56
61
|
this.hookRegistry = createHookRegistry({
|
|
57
|
-
kernel: this.signals
|
|
62
|
+
kernel: this.signals.getKernel(),
|
|
58
63
|
debug,
|
|
59
64
|
})
|
|
60
65
|
}
|
|
@@ -94,6 +99,9 @@ export function applyRegistryMethods(KernelClass: { prototype: Kernel }): void {
|
|
|
94
99
|
* Create PermissionRegistry early so modules can register permissions
|
|
95
100
|
*/
|
|
96
101
|
proto._createPermissionRegistry = function (this: Self): void {
|
|
102
|
+
// Idempotent, built in the constructor (#1906 lot B1) — see _createHookRegistry.
|
|
103
|
+
if (this.permissionRegistry) return
|
|
104
|
+
|
|
97
105
|
this.permissionRegistry = new PermissionRegistry()
|
|
98
106
|
this._registerCorePermissions()
|
|
99
107
|
}
|
|
@@ -102,12 +110,12 @@ export function applyRegistryMethods(KernelClass: { prototype: Kernel }): void {
|
|
|
102
110
|
* Register core system permissions provided by the framework
|
|
103
111
|
*/
|
|
104
112
|
proto._registerCorePermissions = function (this: Self): void {
|
|
105
|
-
this.permissionRegistry
|
|
113
|
+
this.permissionRegistry.register('auth', {
|
|
106
114
|
impersonate: 'Impersonate other users',
|
|
107
115
|
manage: 'Manage authentication settings',
|
|
108
116
|
})
|
|
109
117
|
|
|
110
|
-
this.permissionRegistry
|
|
118
|
+
this.permissionRegistry.register('admin', {
|
|
111
119
|
access: 'Access admin panel',
|
|
112
120
|
config: 'Edit system configuration',
|
|
113
121
|
})
|
|
@@ -119,12 +127,10 @@ export function applyRegistryMethods(KernelClass: { prototype: Kernel }): void {
|
|
|
119
127
|
proto._setupSecurity = function (this: Self): void {
|
|
120
128
|
const { security, entityAuthAdapter } = this.options
|
|
121
129
|
|
|
122
|
-
if (!this.permissionRegistry) {
|
|
123
|
-
this.permissionRegistry = new PermissionRegistry()
|
|
124
|
-
}
|
|
125
|
-
|
|
126
130
|
if (!security) return
|
|
127
131
|
|
|
132
|
+
this._validateSecurityConfig(security as unknown as Record<string, unknown>)
|
|
133
|
+
|
|
128
134
|
// security configured but no user source: every isGranted() falls back
|
|
129
135
|
// to permissive — a newcomer believes gating is on when it isn't (#1388)
|
|
130
136
|
if (!entityAuthAdapter) {
|
|
@@ -173,6 +179,9 @@ export function applyRegistryMethods(KernelClass: { prototype: Kernel }): void {
|
|
|
173
179
|
* Create zone registry for extensible UI composition
|
|
174
180
|
*/
|
|
175
181
|
proto._createZoneRegistry = function (this: Self): void {
|
|
182
|
+
// Idempotent, built in the constructor (#1906 lot B1) — see _createHookRegistry.
|
|
183
|
+
if (this.zoneRegistry) return
|
|
184
|
+
|
|
176
185
|
const debug = this.options.debug ?? false
|
|
177
186
|
this.zoneRegistry = createZoneRegistry({ debug })
|
|
178
187
|
}
|
|
@@ -199,9 +208,15 @@ export function applyRegistryMethods(KernelClass: { prototype: Kernel }): void {
|
|
|
199
208
|
* Create deferred registry for async service loading
|
|
200
209
|
*/
|
|
201
210
|
proto._createDeferredRegistry = function (this: Self): void {
|
|
211
|
+
// Idempotent, built in the constructor (#1906 lot B1) — see _createHookRegistry.
|
|
212
|
+
if (this.deferred) return
|
|
213
|
+
|
|
202
214
|
const debug = this.options.debug ?? false
|
|
215
|
+
// `this.signals` is never null now, so the defensive `?.` this line used
|
|
216
|
+
// to carry is gone. Less guarding, not more: that is the point of closing
|
|
217
|
+
// the window rather than warning about it.
|
|
203
218
|
this.deferred = createDeferredRegistry({
|
|
204
|
-
kernel: this.signals
|
|
219
|
+
kernel: this.signals.getKernel(),
|
|
205
220
|
debug,
|
|
206
221
|
})
|
|
207
222
|
}
|
|
@@ -225,49 +240,93 @@ export function applyRegistryMethods(KernelClass: { prototype: Kernel }): void {
|
|
|
225
240
|
* Create SSEBridge for Server-Sent Events to SignalBus integration
|
|
226
241
|
*/
|
|
227
242
|
/**
|
|
228
|
-
*
|
|
243
|
+
* The known key a typo most likely meant, or undefined.
|
|
229
244
|
*
|
|
230
|
-
*
|
|
231
|
-
*
|
|
232
|
-
*
|
|
233
|
-
*
|
|
234
|
-
*
|
|
235
|
-
*
|
|
236
|
-
*
|
|
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.
|
|
245
|
+
* Case differences and prefixes were all the original matcher caught
|
|
246
|
+
* (#1898), which misses the commonest typo of all: one missing or
|
|
247
|
+
* transposed letter. `securty` suggested nothing, so the reader got
|
|
248
|
+
* "IGNORED" with no hint and no consequence — the two things that make the
|
|
249
|
+
* warning worth printing. An edit distance of one covers an omission, an
|
|
250
|
+
* insertion, a substitution or a transposition; two is allowed only for
|
|
251
|
+
* longer keys, where it stays specific enough not to guess wildly.
|
|
239
252
|
*/
|
|
240
|
-
|
|
241
|
-
const
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
253
|
+
function nearestKnownKey(key: string, known: Set<string>): string | undefined {
|
|
254
|
+
const exact = [...known].find(
|
|
255
|
+
(k) => k.toLowerCase() === key.toLowerCase() || k.startsWith(key) || key.startsWith(k)
|
|
256
|
+
)
|
|
257
|
+
if (exact) return exact
|
|
258
|
+
|
|
259
|
+
const budget = key.length >= 8 ? 2 : 1
|
|
260
|
+
let best: string | undefined
|
|
261
|
+
let bestDistance = budget + 1
|
|
262
|
+
for (const candidate of known) {
|
|
263
|
+
const distance = editDistance(key.toLowerCase(), candidate.toLowerCase(), bestDistance)
|
|
264
|
+
if (distance < bestDistance) {
|
|
265
|
+
bestDistance = distance
|
|
266
|
+
best = candidate
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return bestDistance <= budget ? best : undefined
|
|
270
|
+
}
|
|
257
271
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
272
|
+
/** Damerau-Levenshtein, abandoned as soon as it exceeds `cap`. */
|
|
273
|
+
function editDistance(a: string, b: string, cap: number): number {
|
|
274
|
+
if (Math.abs(a.length - b.length) > cap) return cap + 1
|
|
275
|
+
|
|
276
|
+
let previous: number[] = Array.from({ length: b.length + 1 }, (_, i) => i)
|
|
277
|
+
let beforePrevious: number[] = []
|
|
278
|
+
|
|
279
|
+
for (let i = 1; i <= a.length; i++) {
|
|
280
|
+
const current: number[] = [i]
|
|
281
|
+
let rowBest = i
|
|
282
|
+
for (let j = 1; j <= b.length; j++) {
|
|
283
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1
|
|
284
|
+
let value = Math.min(
|
|
285
|
+
(current[j - 1] as number) + 1,
|
|
286
|
+
(previous[j] as number) + 1,
|
|
287
|
+
(previous[j - 1] as number) + cost
|
|
288
|
+
)
|
|
289
|
+
// Transposition: `raods` for `roads`.
|
|
290
|
+
if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) {
|
|
291
|
+
value = Math.min(value, (beforePrevious[j - 2] as number) + 1)
|
|
292
|
+
}
|
|
293
|
+
current[j] = value
|
|
294
|
+
rowBest = Math.min(rowBest, value)
|
|
295
|
+
}
|
|
296
|
+
if (rowBest > cap) return cap + 1
|
|
297
|
+
beforePrevious = previous
|
|
298
|
+
previous = current
|
|
265
299
|
}
|
|
300
|
+
return previous[b.length] as number
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Warn about keys a config object does not recognise (#1906 lot B2).
|
|
305
|
+
*
|
|
306
|
+
* Extracted from the `sse` validator of #1898, which was the only one of its
|
|
307
|
+
* kind: every other qdadm config accepted anything in silence. The shape of
|
|
308
|
+
* the message is the lesson from that incident and is kept intact — it names
|
|
309
|
+
* what happens INSTEAD of what was asked, because "ignored" reads as "no
|
|
310
|
+
* effect" rather than "falls back to something else", and that reading is
|
|
311
|
+
* what cost a durable auth token in access logs.
|
|
312
|
+
*
|
|
313
|
+
* Only for CLOSED shapes. A config that deliberately forwards unknown keys
|
|
314
|
+
* elsewhere — `debugBar`, whose extra options reach the DebugModule — must
|
|
315
|
+
* not be passed through here: a warning that cries wolf is worse than none
|
|
316
|
+
* (ADR 0011).
|
|
317
|
+
*/
|
|
318
|
+
proto._warnUnknownKeys = function (
|
|
319
|
+
this: Self,
|
|
320
|
+
scope: string,
|
|
321
|
+
config: Record<string, unknown>,
|
|
322
|
+
known: Set<string>,
|
|
323
|
+
consequences: Record<string, string> = {}
|
|
324
|
+
): void {
|
|
325
|
+
const unknown = Object.keys(config).filter((key) => !known.has(key))
|
|
326
|
+
if (!unknown.length) return
|
|
266
327
|
|
|
267
328
|
for (const key of unknown) {
|
|
268
|
-
const near =
|
|
269
|
-
(k) => k.toLowerCase() === key.toLowerCase() || k.startsWith(key) || key.startsWith(k)
|
|
270
|
-
)
|
|
329
|
+
const near = nearestKnownKey(key, known)
|
|
271
330
|
// Look the consequence up under the NEAREST KNOWN key: a typo like
|
|
272
331
|
// `getTokens` means `getToken` is absent, and it is that absence whose
|
|
273
332
|
// effect the reader needs to hear.
|
|
@@ -275,13 +334,110 @@ export function applyRegistryMethods(KernelClass: { prototype: Kernel }): void {
|
|
|
275
334
|
const because = consequence ? ` — ${consequence}` : ''
|
|
276
335
|
const suggestion = near ? ` Did you mean "${near}"?` : ''
|
|
277
336
|
console.warn(
|
|
278
|
-
`[Kernel]
|
|
337
|
+
`[Kernel] ${scope}${key} is not a recognised option and is IGNORED${because}.` +
|
|
279
338
|
`${suggestion} This usually means the installed qdadm predates the option ` +
|
|
280
339
|
`— check the version before assuming the key has no effect.`
|
|
281
340
|
)
|
|
282
341
|
}
|
|
283
342
|
}
|
|
284
343
|
|
|
344
|
+
/**
|
|
345
|
+
* Every option `KernelOptions` accepts (#1906 lot B2).
|
|
346
|
+
*
|
|
347
|
+
* A misspelled TOP-LEVEL key is the worst silence of the family: it does not
|
|
348
|
+
* degrade a feature, it removes a whole section of configuration. `securty:`
|
|
349
|
+
* means no security config at all, and nothing says so. TypeScript catches
|
|
350
|
+
* it; the many consumers whose module files are plain JavaScript get
|
|
351
|
+
* nothing.
|
|
352
|
+
*
|
|
353
|
+
* Kept in sync with the interface by a test that reads `Kernel.types.ts` and
|
|
354
|
+
* compares — a hand-maintained list that drifts would start crying wolf,
|
|
355
|
+
* which is the failure mode this whole ticket exists to avoid.
|
|
356
|
+
*/
|
|
357
|
+
const KNOWN_KERNEL_OPTIONS = new Set([
|
|
358
|
+
'apiClient', 'app', 'authAdapter', 'authTypes', 'basePath', 'coreRoutes',
|
|
359
|
+
'debug', 'debugBar', 'defaultEntityCacheTtlMs', 'entityAuthAdapter',
|
|
360
|
+
'eventRouter', 'existingApp', 'existingRouter', 'existingSignals',
|
|
361
|
+
'features', 'hashMode', 'homeRoute', 'i18n', 'layouts', 'managerRegistry',
|
|
362
|
+
'managerResolver', 'managers', 'moduleDefs', 'modules', 'modulesOptions',
|
|
363
|
+
'notifications', 'onAuthExpired', 'pages', 'parentParamMode', 'primevue',
|
|
364
|
+
'root', 'routeParamResolver', 'routePrefix', 'routeState', 'sectionOrder',
|
|
365
|
+
'security', 'sse', 'storageResolver', 'toast', 'warmup',
|
|
366
|
+
])
|
|
367
|
+
|
|
368
|
+
proto._validateKernelOptions = function (this: Self): void {
|
|
369
|
+
this._warnUnknownKeys('', this.options as Record<string, unknown>, KNOWN_KERNEL_OPTIONS, {
|
|
370
|
+
security: 'no role hierarchy or permissions will be applied — every check falls back to the default',
|
|
371
|
+
sse: 'no server-sent events will be connected',
|
|
372
|
+
debugBar: 'no debug bar will be mounted',
|
|
373
|
+
layouts: 'pages will fall back to the base layout',
|
|
374
|
+
i18n: 'translations will not be configured',
|
|
375
|
+
authAdapter: 'the app will run unauthenticated',
|
|
376
|
+
})
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Warn about unrecognised `security` keys (#1906 lot B2).
|
|
381
|
+
*
|
|
382
|
+
* Chosen first, and by damage: a misspelled key here means a role hierarchy
|
|
383
|
+
* or a permission map is simply absent, and every check then falls through
|
|
384
|
+
* to whatever the default is. Nothing fails, nothing warns, and the app is
|
|
385
|
+
* open where its author believed it closed.
|
|
386
|
+
*/
|
|
387
|
+
proto._validateSecurityConfig = function (this: Self, security: Record<string, unknown>): void {
|
|
388
|
+
this._warnUnknownKeys(
|
|
389
|
+
'security.',
|
|
390
|
+
security,
|
|
391
|
+
new Set(['role_hierarchy', 'role_permissions', 'role_labels', 'entity_permissions', 'rolesProvider']),
|
|
392
|
+
{
|
|
393
|
+
role_hierarchy: 'roles will not inherit from one another',
|
|
394
|
+
role_permissions: 'no role will carry any permission',
|
|
395
|
+
entity_permissions: 'per-entity permissions will not be generated',
|
|
396
|
+
rolesProvider: "the current user's roles will not be resolved",
|
|
397
|
+
}
|
|
398
|
+
)
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Warn about unrecognised `sse` keys (#1898 lot A).
|
|
403
|
+
*
|
|
404
|
+
* A consumer configured `sse.getToken` against a version that predated it.
|
|
405
|
+
* It was not rejected — it was ignored, and the bridge fell back to the
|
|
406
|
+
* session's DURABLE token, which then travelled in the query string of every
|
|
407
|
+
* `/events` request and into logs that outlive the session by months. No
|
|
408
|
+
* error, no warning, and a stream that worked perfectly.
|
|
409
|
+
*
|
|
410
|
+
* The warning therefore names what happens INSTEAD, not merely that the key
|
|
411
|
+
* was ignored: "ignored" reads as "no effect", not as "falls back to a more
|
|
412
|
+
* sensitive secret". That reading is what made the failure invisible.
|
|
413
|
+
*/
|
|
414
|
+
proto._validateSseConfig = function (this: Self, sse: SSEConfig): void {
|
|
415
|
+
this._warnUnknownKeys(
|
|
416
|
+
'sse.',
|
|
417
|
+
sse as unknown as Record<string, unknown>,
|
|
418
|
+
new Set([
|
|
419
|
+
'url',
|
|
420
|
+
'reconnectDelay',
|
|
421
|
+
'signalPrefix',
|
|
422
|
+
'autoConnect',
|
|
423
|
+
'withCredentials',
|
|
424
|
+
'tokenParam',
|
|
425
|
+
'events',
|
|
426
|
+
'entities',
|
|
427
|
+
'getToken',
|
|
428
|
+
'connectOnSignal',
|
|
429
|
+
'disconnectOnSignal',
|
|
430
|
+
]),
|
|
431
|
+
{
|
|
432
|
+
getToken:
|
|
433
|
+
'the session auth token will be sent in the stream URL instead — ' +
|
|
434
|
+
'and query strings reach access logs',
|
|
435
|
+
entities: 'no entity cache will be invalidated from the stream',
|
|
436
|
+
tokenParam: 'the token will be sent under the default name "token"',
|
|
437
|
+
}
|
|
438
|
+
)
|
|
439
|
+
}
|
|
440
|
+
|
|
285
441
|
proto._createSSEBridge = function (this: Self): void {
|
|
286
442
|
const { sse, authAdapter } = this.options
|
|
287
443
|
if (!sse?.url) return
|
package/src/kernel/Kernel.ts
CHANGED
|
@@ -94,18 +94,18 @@ export class Kernel {
|
|
|
94
94
|
* window existed for no reason. Removing it beats warning about it
|
|
95
95
|
* (ADR 0011).
|
|
96
96
|
*/
|
|
97
|
-
signals
|
|
97
|
+
signals!: SignalBus
|
|
98
98
|
orchestrator: Orchestrator | null = null
|
|
99
|
-
zoneRegistry
|
|
100
|
-
hookRegistry
|
|
101
|
-
deferred
|
|
99
|
+
zoneRegistry!: ZoneRegistry
|
|
100
|
+
hookRegistry!: HookRegistry
|
|
101
|
+
deferred!: DeferredRegistry
|
|
102
102
|
eventRouter: EventRouter | null = null
|
|
103
103
|
sseBridge: SSEBridge | null = null
|
|
104
104
|
/** Routes external entity changes to cache invalidation (#1888). */
|
|
105
105
|
liveEntityRouter: LiveEntityRouter | null = null
|
|
106
106
|
layoutComponents: InternalLayoutComponents | null = null
|
|
107
107
|
securityChecker: SecurityChecker | null = null
|
|
108
|
-
permissionRegistry
|
|
108
|
+
permissionRegistry!: PermissionRegistry
|
|
109
109
|
moduleLoader: ModuleLoader | null = null
|
|
110
110
|
activeStack: ActiveStack | null = null
|
|
111
111
|
stackHydrator: StackHydrator | null = null
|
|
@@ -181,11 +181,28 @@ export class Kernel {
|
|
|
181
181
|
|
|
182
182
|
this.options = options
|
|
183
183
|
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
//
|
|
187
|
-
//
|
|
184
|
+
// Everything that CAN exist from here on, does (#1905 lot B, #1906 lot B1).
|
|
185
|
+
//
|
|
186
|
+
// These five have no dependency beyond `options` and each other, and the
|
|
187
|
+
// natural place to wire a kernel is right after `new Kernel()` — so they
|
|
188
|
+
// have to be usable there. Someone reached for `kernel.signals` before
|
|
189
|
+
// createApp(), got `null`, and `null?.emit?.()` swallowed it without a
|
|
190
|
+
// word; there were seventeen properties shaped like that, and these are
|
|
191
|
+
// the ones that need not have been.
|
|
192
|
+
//
|
|
193
|
+
// Every creator is idempotent, so the later calls in createApp() keep
|
|
194
|
+
// these instances rather than orphaning whatever was registered on them
|
|
195
|
+
// in between — which would be the same silent failure wearing a new hat.
|
|
196
|
+
// Before anything reads them (#1906 lot B2): a misspelled top-level key
|
|
197
|
+
// removes a whole section of configuration, and TypeScript only catches
|
|
198
|
+
// it for consumers whose module files are TypeScript.
|
|
199
|
+
this._validateKernelOptions()
|
|
200
|
+
|
|
188
201
|
this._createSignalBus()
|
|
202
|
+
this._createHookRegistry()
|
|
203
|
+
this._createZoneRegistry()
|
|
204
|
+
this._createDeferredRegistry()
|
|
205
|
+
this._createPermissionRegistry()
|
|
189
206
|
}
|
|
190
207
|
|
|
191
208
|
/**
|
|
@@ -479,6 +496,14 @@ export interface Kernel {
|
|
|
479
496
|
_createDeferredRegistry(): void
|
|
480
497
|
_createEventRouter(): void
|
|
481
498
|
_validateSseConfig(sse: SSEConfigType): void
|
|
499
|
+
_validateSecurityConfig(security: Record<string, unknown>): void
|
|
500
|
+
_validateKernelOptions(): void
|
|
501
|
+
_warnUnknownKeys(
|
|
502
|
+
scope: string,
|
|
503
|
+
config: Record<string, unknown>,
|
|
504
|
+
known: Set<string>,
|
|
505
|
+
consequences?: Record<string, string>
|
|
506
|
+
): void
|
|
482
507
|
_createSSEBridge(): void
|
|
483
508
|
|
|
484
509
|
// Vue (Kernel.vue.ts)
|
|
@@ -12,6 +12,7 @@ import type { EntityAuthAdapter } from '../entity/auth/EntityAuthAdapter'
|
|
|
12
12
|
import type { RoleProvider } from '../security/RolesProvider'
|
|
13
13
|
import type { I18nOptions } from '../i18n/types'
|
|
14
14
|
import type { ApiClientSource } from '../api/apiClient'
|
|
15
|
+
import type { RouteStatePersister } from '../routeState'
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
18
|
* Auth adapter interface (app-level authentication)
|
|
@@ -70,10 +71,12 @@ export interface Pages {
|
|
|
70
71
|
export interface LayoutComponents {
|
|
71
72
|
list?: Component | null
|
|
72
73
|
form?: Component | null
|
|
74
|
+
show?: Component | null
|
|
73
75
|
dashboard?: Component | null
|
|
74
76
|
base?: Component | null
|
|
75
77
|
ListLayout?: Component
|
|
76
78
|
FormLayout?: Component
|
|
79
|
+
ShowLayout?: Component
|
|
77
80
|
DashboardLayout?: Component
|
|
78
81
|
BaseLayout?: Component
|
|
79
82
|
}
|
|
@@ -216,6 +219,21 @@ export interface KernelOptions {
|
|
|
216
219
|
defaultEntityCacheTtlMs?: number
|
|
217
220
|
eventRouter?: RoutesConfig
|
|
218
221
|
sse?: SSEConfig
|
|
222
|
+
/**
|
|
223
|
+
* Where screens remember their route state by default (#2146): a slug the
|
|
224
|
+
* framework knows (`'url'`, `'local_storage'`, `'cookie'`, `'memory'`,
|
|
225
|
+
* `'none'`), or a persister instance.
|
|
226
|
+
*
|
|
227
|
+
* The app-wide floor. An entity overrides it for every screen showing that
|
|
228
|
+
* entity; a single list overrides it for itself. Unset means `'url'`, which
|
|
229
|
+
* is the only medium where what you are looking at is also what you can
|
|
230
|
+
* send someone.
|
|
231
|
+
*
|
|
232
|
+
* An unknown slug throws at list construction rather than falling back —
|
|
233
|
+
* a persistence choice that quietly does something else is the failure
|
|
234
|
+
* ADR 0011 forbids.
|
|
235
|
+
*/
|
|
236
|
+
routeState?: string | RouteStatePersister
|
|
219
237
|
debugBar?: DebugBarConfig
|
|
220
238
|
notifications?: NotificationsConfig
|
|
221
239
|
toast?: Record<string, unknown>
|
|
@@ -293,6 +311,7 @@ export interface KernelOptions {
|
|
|
293
311
|
export interface InternalLayoutComponents {
|
|
294
312
|
list: Component | null
|
|
295
313
|
form: Component | null
|
|
314
|
+
show: Component | null
|
|
296
315
|
dashboard: Component | null
|
|
297
316
|
base: Component | null
|
|
298
317
|
}
|
package/src/kernel/Kernel.vue.ts
CHANGED
|
@@ -115,6 +115,10 @@ export function applyVueMethods(KernelClass: { prototype: Kernel }): void {
|
|
|
115
115
|
this.layoutComponents = {
|
|
116
116
|
list: layouts.list || layouts.ListLayout || null,
|
|
117
117
|
form: layouts.form || layouts.FormLayout || null,
|
|
118
|
+
// Until #1922 this literal had no `show` key, so an app that passed one
|
|
119
|
+
// had it dropped without a word while crud({ show }) kept emitting
|
|
120
|
+
// `layout: 'show'` for a layout nothing could honour.
|
|
121
|
+
show: layouts.show || layouts.ShowLayout || null,
|
|
118
122
|
dashboard: layouts.dashboard || layouts.DashboardLayout || null,
|
|
119
123
|
base: layouts.base || layouts.BaseLayout || null,
|
|
120
124
|
}
|
|
@@ -319,6 +323,9 @@ export function applyVueMethods(KernelClass: { prototype: Kernel }): void {
|
|
|
319
323
|
}
|
|
320
324
|
|
|
321
325
|
app.provide('qdadmHooks', this.hookRegistry)
|
|
326
|
+
// The app-wide route-state default (#2146). Provided even when unset, so
|
|
327
|
+
// a list can tell "nobody chose" from "somebody chose and it was null".
|
|
328
|
+
app.provide('qdadmRouteState', this.options.routeState ?? null)
|
|
322
329
|
app.provide('qdadmDeferred', this.deferred)
|
|
323
330
|
app.provide('qdadmLayoutComponents', this.layoutComponents)
|
|
324
331
|
// Surface PrimeVue presence so host-rendered <QdadmRoot /> can
|
|
@@ -9,9 +9,13 @@
|
|
|
9
9
|
* The string helpers (`singularize`, `toKebab`, `capitalize`) are kept
|
|
10
10
|
* module-local — they're pure, stateless, and were only used by `crud()` and
|
|
11
11
|
* `childPage()`. Keeping them off the prototype keeps the public shape clean.
|
|
12
|
+
* `singularize` is a thin call into the vendored `pluralize` since #1923: the
|
|
13
|
+
* hand-rolled version disagreed with the one `EntityManager` uses, and a
|
|
14
|
+
* framework cannot afford two answers to the same question.
|
|
12
15
|
*/
|
|
13
16
|
|
|
14
17
|
import type { RouteRecordRaw } from 'vue-router'
|
|
18
|
+
import pluralize from '../utils/pluralize'
|
|
15
19
|
import { registry, getRoutes } from '../module/moduleRegistry'
|
|
16
20
|
import type {
|
|
17
21
|
ChildPageOptions,
|
|
@@ -26,14 +30,21 @@ import type { KernelContext } from './KernelContext'
|
|
|
26
30
|
// #1196 Phase B — this-typing against the real KernelContext shape (was Self = any)
|
|
27
31
|
type Self = KernelContext
|
|
28
32
|
|
|
29
|
-
/**
|
|
33
|
+
/**
|
|
34
|
+
* Singularize an entity name (#1923).
|
|
35
|
+
*
|
|
36
|
+
* This used to be six hand-rolled lines, while `EntityManager.routePrefix`
|
|
37
|
+
* used the vendored `pluralize`. Two engines, and they disagreed: `crud()`
|
|
38
|
+
* named a route `people-show` while `useListPage` looked for `person-show`,
|
|
39
|
+
* so every navigation helper missed silently — no error at boot, nothing until
|
|
40
|
+
* someone clicked. They agreed on every regular plural, which is exactly why
|
|
41
|
+
* it went unnoticed.
|
|
42
|
+
*
|
|
43
|
+
* One engine now, the real one. `pluralize` is vendored (#1454) and imports
|
|
44
|
+
* nothing, so there is no cycle to fear from calling it here.
|
|
45
|
+
*/
|
|
30
46
|
function singularize(plural: string): string {
|
|
31
|
-
|
|
32
|
-
if (plural.endsWith('ses') || plural.endsWith('xes') || plural.endsWith('zes')) {
|
|
33
|
-
return plural.slice(0, -2)
|
|
34
|
-
}
|
|
35
|
-
if (plural.endsWith('s') && !plural.endsWith('ss')) return plural.slice(0, -1)
|
|
36
|
-
return plural
|
|
47
|
+
return pluralize.singular(plural)
|
|
37
48
|
}
|
|
38
49
|
|
|
39
50
|
/** Convert camelCase to kebab-case (e.g. 'botTasks' → 'bot-tasks') */
|
package/src/plugin.ts
CHANGED
|
@@ -107,7 +107,7 @@ export interface QdadmPlugin {
|
|
|
107
107
|
export function createQdadm(options: QdadmOptions): QdadmPlugin {
|
|
108
108
|
return {
|
|
109
109
|
install(app: App) {
|
|
110
|
-
//
|
|
110
|
+
// The contracts this plugin cannot run without.
|
|
111
111
|
if (!options.entityFactory && !options.orchestrator && !options.managers) {
|
|
112
112
|
throw new Error('[qdadm] entityFactory, orchestrator, or managers required')
|
|
113
113
|
}
|