@quazardous/qdadm 2.18.0 → 2.19.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quazardous/qdadm",
3
- "version": "2.18.0",
3
+ "version": "2.19.0",
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.2",
57
+ "@quazardous/qdcore": "^1.2.0",
58
58
  "@quazardous/qddebug": "^1.2.0",
59
59
  "@quazardous/quarkernel": "^2.1.0",
60
60
  "yaml": "^2.8.4"
@@ -63,6 +63,7 @@
63
63
  "pinia": "^2.0.0 || ^3.0.0 || ^4.0.0",
64
64
  "primevue": "^4.0.0",
65
65
  "sass-embedded": "^1.0.0",
66
+ "vanilla-jsoneditor": "^3.13.0",
66
67
  "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0",
67
68
  "vue": "^3.3.0",
68
69
  "vue-router": "^4.0.0 || ^5.0.0"
@@ -78,12 +79,9 @@
78
79
  "optional": true
79
80
  },
80
81
  "sass-embedded": {
81
- "optional": "true"
82
+ "optional": true
82
83
  }
83
84
  },
84
- "optionalDependencies": {
85
- "vanilla-jsoneditor": "^0.23.0"
86
- },
87
85
  "keywords": [
88
86
  "vue",
89
87
  "vue3",
@@ -15,7 +15,17 @@
15
15
  */
16
16
 
17
17
  import { ref, watch, onMounted, onUnmounted, nextTick } from 'vue'
18
- import { JSONEditor, type Content, type ContentErrors, type OnChangeStatus, type Mode } from 'vanilla-jsoneditor'
18
+ // vanilla-jsoneditor 3.x (#1901): the `new JSONEditor(...)` constructor is
19
+ // deprecated — `JSONEditor` is now the Svelte component itself, and the
20
+ // instance type is exported as `JsonEditor`.
21
+ import {
22
+ createJSONEditor,
23
+ type JsonEditor,
24
+ type Content,
25
+ type ContentErrors,
26
+ type OnChangeStatus,
27
+ type Mode,
28
+ } from 'vanilla-jsoneditor'
19
29
  import { buildJsonValidator, type Validator, type JSONSchema, type JsonValidatorOptions } from './jsonValidator'
20
30
 
21
31
  type JsonValue = Record<string, unknown> | unknown[] | string | null
@@ -74,8 +84,8 @@ const emit = defineEmits<{
74
84
  'error': [errors: ContentErrors]
75
85
  }>()
76
86
 
77
- const containerRef = ref<HTMLElement | null>(null)
78
- let editor: JSONEditor | null = null
87
+ const containerRef = ref<HTMLDivElement | null>(null)
88
+ let editor: JsonEditor | null = null
79
89
  // Flag to prevent onChange from firing during programmatic updates (instance-specific)
80
90
  const updatingFromProp = ref<boolean>(false)
81
91
 
@@ -102,7 +112,7 @@ onMounted(() => {
102
112
  json: parseValue(props.modelValue)
103
113
  }
104
114
 
105
- editor = new JSONEditor({
115
+ editor = createJSONEditor({
106
116
  target: containerRef.value,
107
117
  props: {
108
118
  content,
@@ -34,6 +34,10 @@ import { useRoute } from 'vue-router'
34
34
  export const LAYOUT_TYPES = {
35
35
  LIST: 'list',
36
36
  FORM: 'form',
37
+ // `show` was emitted by crud({ show }) long before it existed here (#1922).
38
+ // The resolver returned it, found no component under that key, and fell
39
+ // back to `base` — gracefully, which is exactly what made it invisible.
40
+ SHOW: 'show',
37
41
  DASHBOARD: 'dashboard',
38
42
  BASE: 'base',
39
43
  } as const
@@ -46,6 +50,7 @@ export type LayoutType = (typeof LAYOUT_TYPES)[keyof typeof LAYOUT_TYPES]
46
50
  export interface LayoutComponentsMap {
47
51
  list?: Component | null
48
52
  form?: Component | null
53
+ show?: Component | null
49
54
  dashboard?: Component | null
50
55
  base?: Component | null
51
56
  [key: string]: Component | null | undefined
@@ -63,6 +68,7 @@ interface PageNamePattern {
63
68
  const PAGE_NAME_PATTERNS: PageNamePattern[] = [
64
69
  { pattern: /List(Page)?$/i, layout: LAYOUT_TYPES.LIST },
65
70
  { pattern: /(Edit|Create|Form)(Page)?$/i, layout: LAYOUT_TYPES.FORM },
71
+ { pattern: /Show(Page)?$/i, layout: LAYOUT_TYPES.SHOW },
66
72
  { pattern: /Dashboard(Page)?$/i, layout: LAYOUT_TYPES.DASHBOARD },
67
73
  ]
68
74
 
@@ -70,6 +76,10 @@ const PAGE_NAME_PATTERNS: PageNamePattern[] = [
70
76
  const ROUTE_NAME_PATTERNS: PageNamePattern[] = [
71
77
  { pattern: /-list$/i, layout: LAYOUT_TYPES.LIST },
72
78
  { pattern: /-(edit|create)$/i, layout: LAYOUT_TYPES.FORM },
79
+ // Load-bearing, not decoration: an app that declares its detail route by
80
+ // hand rather than through crud({ show }) has no meta.layout to go on, and
81
+ // this is the only path that can reach `show` for it (#1922).
82
+ { pattern: /-show$/i, layout: LAYOUT_TYPES.SHOW },
73
83
  { pattern: /-dashboard$/i, layout: LAYOUT_TYPES.DASHBOARD },
74
84
  // Common patterns without suffix
75
85
  { pattern: /^dashboard$/i, layout: LAYOUT_TYPES.DASHBOARD },
@@ -28,7 +28,7 @@ import {
28
28
  provide,
29
29
  type Ref,
30
30
  } from 'vue'
31
- import { useRouter, useRoute } from 'vue-router'
31
+ import { useRouter, useRoute, type RouteLocationRaw } from 'vue-router'
32
32
  import { useConfirm } from 'primevue/useconfirm'
33
33
  import { useHooks } from './useHooks.js'
34
34
  import { useEntityItemPage, type ParentConfig, type UseEntityItemPageReturn } from './useEntityItemPage.js'
@@ -855,21 +855,55 @@ export function useListPage<T = unknown>(config: UseListPageOptions<T>): UseList
855
855
  return { name: `${routePrefix}-create` }
856
856
  }
857
857
 
858
+ /** Route names already reported as missing — one warning each. */
859
+ const warnedMissingRoutes = new Set<string>()
860
+
861
+ /**
862
+ * Navigate by route name, and say something when that name does not exist
863
+ * (#1923 lot 2).
864
+ *
865
+ * The failure this catches is quiet by nature: the route prefix is derived
866
+ * from the entity name, so a mismatch between the side that NAMES routes
867
+ * and the side that LOOKS THEM UP produces a push to a name nobody
868
+ * registered. Nothing happens, and nothing says why — the divergence that
869
+ * caused #1923 behaved exactly like this, invisible until someone clicked.
870
+ *
871
+ * We warn at the moment of use rather than auditing the prefix upfront: an
872
+ * entity that only ever appears as a child of another has no routes under
873
+ * its own prefix and is perfectly healthy, so an upfront check would cry
874
+ * wolf. A warning nobody trusts is worse than none (ADR 0011).
875
+ */
876
+ function pushNamedRoute(name: string, params?: Record<string, unknown>): void {
877
+ if (!router.hasRoute(name)) {
878
+ if (!warnedMissingRoutes.has(name)) {
879
+ warnedMissingRoutes.add(name)
880
+ console.warn(
881
+ `[qdadm] No route named "${name}" — this navigation does nothing. ` +
882
+ `The list for "${entity}" derives it from the route prefix ` +
883
+ `"${routePrefix}". Either that page is not declared, or the ` +
884
+ `prefix disagrees with the one its routes were registered under ` +
885
+ `(pass routePrefix on the entity, or on useListPage, to pin it).`
886
+ )
887
+ }
888
+ return
889
+ }
890
+ router.push({ name, ...(params ? { params } : {}) } as RouteLocationRaw)
891
+ }
892
+
858
893
  function goToCreate(): void {
859
- router.push(findCreateRoute() as { name: string })
894
+ const target = findCreateRoute() as { name: string; params?: Record<string, unknown> }
895
+ pushNamedRoute(target.name, target.params)
860
896
  }
861
897
 
862
898
  function goToEdit(item: unknown): void {
863
- router.push({
864
- name: `${routePrefix}-edit`,
865
- params: { [manager.idField]: (item as Record<string, unknown>)[resolvedDataKey] as string },
899
+ pushNamedRoute(`${routePrefix}-edit`, {
900
+ [manager.idField]: (item as Record<string, unknown>)[resolvedDataKey] as string,
866
901
  })
867
902
  }
868
903
 
869
904
  function goToShow(item: unknown): void {
870
- router.push({
871
- name: `${routePrefix}-show`,
872
- params: { [manager.idField]: (item as Record<string, unknown>)[resolvedDataKey] as string },
905
+ pushNamedRoute(`${routePrefix}-show`, {
906
+ [manager.idField]: (item as Record<string, unknown>)[resolvedDataKey] as string,
873
907
  })
874
908
  }
875
909
 
@@ -196,7 +196,7 @@ function findSchemaForEntity(
196
196
  * const users = await usersManager.list()
197
197
  * ```
198
198
  */
199
- export function createManagers(config: CreateManagersConfig): Map<string, EntityManager> {
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 { createManagers, type CreateManagersConfig, type EntityConfig } from './createManagers'
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'
@@ -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!.getKernel(),
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!.register('auth', {
113
+ this.permissionRegistry.register('auth', {
106
114
  impersonate: 'Impersonate other users',
107
115
  manage: 'Manage authentication settings',
108
116
  })
109
117
 
110
- this.permissionRegistry!.register('admin', {
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?.getKernel() || null,
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
- * Warn about unrecognised `sse` keys (#1898 lot A).
243
+ * The known key a typo most likely meant, or undefined.
229
244
  *
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.
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
- 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
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
- // 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"',
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 = [...known].find(
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] sse.${key} is not a recognised option and is IGNORED${because}.` +
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', 'sectionOrder', 'security',
365
+ '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
@@ -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: SignalBus | null = null
97
+ signals!: SignalBus
98
98
  orchestrator: Orchestrator | null = null
99
- zoneRegistry: ZoneRegistry | null = null
100
- hookRegistry: HookRegistry | null = null
101
- deferred: DeferredRegistry | null = null
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: PermissionRegistry | null = null
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
- // The bus exists from here on (#1905 lot B). It has no dependencies, and
185
- // the natural place to wire it is right after `new Kernel()` — so it must
186
- // be usable there. _createSignalBus() is idempotent, so the later call in
187
- // createApp() keeps this instance rather than orphaning its listeners.
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)
@@ -70,10 +70,12 @@ export interface Pages {
70
70
  export interface LayoutComponents {
71
71
  list?: Component | null
72
72
  form?: Component | null
73
+ show?: Component | null
73
74
  dashboard?: Component | null
74
75
  base?: Component | null
75
76
  ListLayout?: Component
76
77
  FormLayout?: Component
78
+ ShowLayout?: Component
77
79
  DashboardLayout?: Component
78
80
  BaseLayout?: Component
79
81
  }
@@ -293,6 +295,7 @@ export interface KernelOptions {
293
295
  export interface InternalLayoutComponents {
294
296
  list: Component | null
295
297
  form: Component | null
298
+ show: Component | null
296
299
  dashboard: Component | null
297
300
  base: Component | null
298
301
  }
@@ -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
  }
@@ -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
- /** Singularize a plural word (simple English rules) */
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
- if (plural.endsWith('ies')) return plural.slice(0, -3) + 'y'
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') */