@conduction/nextcloud-vue 1.0.0-beta.20 → 1.0.0-beta.22

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.
@@ -0,0 +1,268 @@
1
+ /**
2
+ * Manifest `@resolve:` sentinel resolver.
3
+ *
4
+ * Implements the `manifest-resolve-sentinel` capability: walks the
5
+ * `pages[].config` subtrees of an assembled manifest and replaces every
6
+ * fully-matched `@resolve:<key>` string with the result of the consuming
7
+ * app's `IAppConfig` lookup for `<key>`. Other manifest paths
8
+ * (`route`, `id`, top-level fields, `menu[]`, `pages[].component` etc.)
9
+ * are intentionally untouched — sentinels there are router / registry
10
+ * invariants and the schema validator rejects them.
11
+ *
12
+ * Resolution source (canonical, per spec):
13
+ *
14
+ * 1. `@nextcloud/initial-state` provisioned key
15
+ * `app-{appId}-{key}` — zero-network, preferred.
16
+ * 2. Runtime `GET /index.php/apps/{appId}/api/configs/{key}` — falls
17
+ * through silently on 4xx / network error.
18
+ * 3. `null` — unresolved.
19
+ *
20
+ * Empty-state semantics: an unset / empty value substitutes `null` (not
21
+ * empty string) and is accumulated into the returned `unresolved` array
22
+ * so consumers can render an admin warning. A `console.warn` is emitted
23
+ * once per unresolved key with the offending sentinel.
24
+ *
25
+ * The resolver is intentionally split into a synchronous walk + an
26
+ * asynchronous batch resolution: every sentinel is collected first, then
27
+ * each unique `(appId, key)` is resolved exactly once (initial-state
28
+ * lookup is synchronous; runtime fetch is per-key cached for the page
29
+ * lifetime), and finally the manifest is rewritten in a second walk
30
+ * with the resolved values. This makes the substitution deterministic
31
+ * — five `@resolve:foo` references in five pages share one fetch.
32
+ *
33
+ * @module utils/resolveManifestSentinels
34
+ */
35
+
36
+ const SENTINEL_PATTERN = /^@resolve:([a-z][a-z0-9_-]*)$/
37
+
38
+ /**
39
+ * Process-wide cache of resolved IAppConfig values, keyed by
40
+ * `${appId}::${key}`. Cleared via `clearResolveCache()` (test-only).
41
+ *
42
+ * @type {Map<string, Promise<*>>}
43
+ */
44
+ const resolveCache = new Map()
45
+
46
+ /**
47
+ * Test-only helper to reset the per-page resolve cache between runs.
48
+ * Production callers do not need this — the cache is page-lifetime by
49
+ * design, consistent with the manifest's load-once model.
50
+ *
51
+ * @return {void}
52
+ */
53
+ export function clearResolveCache() {
54
+ resolveCache.clear()
55
+ }
56
+
57
+ /**
58
+ * Walk the manifest's `pages[].config` subtrees and replace every
59
+ * `@resolve:<key>` sentinel with the resolved IAppConfig value.
60
+ *
61
+ * Returns a Promise resolving to `{ manifest, unresolved }`:
62
+ * - `manifest` — a NEW manifest object with sentinels substituted; the
63
+ * input is NOT mutated.
64
+ * - `unresolved` — array of IAppConfig keys whose sentinels resolved
65
+ * to `null` (unset / empty / fetch failure). Useful for surfacing
66
+ * "n settings unconfigured" admin warnings.
67
+ *
68
+ * Sentinels OUTSIDE `pages[].config` are left intact; the schema
69
+ * validator rejects them downstream so consumers see a clear error
70
+ * rather than a silent substitution that breaks routing or registry
71
+ * lookups.
72
+ *
73
+ * @param {object} manifest The merged (bundled + backend) manifest.
74
+ * Walked but not mutated.
75
+ * @param {string} appId Nextcloud app ID. Used to scope the
76
+ * IAppConfig lookup namespace.
77
+ * @param {object} [options] Resolver overrides.
78
+ * @param {Function} [options.getAppConfigValue] Async (appId, key) =>
79
+ * value resolver. Override for tests; defaults to the
80
+ * initial-state-then-fetch chain documented above.
81
+ * @param {Function} [options.warn] Override for `console.warn`. Used in
82
+ * tests to capture warning calls without polluting test output.
83
+ * @return {Promise<{ manifest: object, unresolved: string[] }>}
84
+ */
85
+ export async function resolveManifestSentinels(manifest, appId, options = {}) {
86
+ if (!isPlainObject(manifest)) {
87
+ return { manifest, unresolved: [] }
88
+ }
89
+
90
+ const getAppConfigValue = options.getAppConfigValue ?? defaultGetAppConfigValue
91
+ const warn = options.warn ?? ((...args) => {
92
+ // eslint-disable-next-line no-console
93
+ console.warn(...args)
94
+ })
95
+
96
+ // Phase 1: scan for sentinels under pages[].config only. We only
97
+ // need the unique key set — the second walk does the substitution.
98
+ const keys = new Set()
99
+ const pages = Array.isArray(manifest.pages) ? manifest.pages : []
100
+ for (const page of pages) {
101
+ if (!isPlainObject(page) || !isPlainObject(page.config)) continue
102
+ collectSentinelKeys(page.config, keys)
103
+ }
104
+
105
+ if (keys.size === 0) {
106
+ return { manifest, unresolved: [] }
107
+ }
108
+
109
+ // Phase 2: resolve each unique (appId, key) exactly once.
110
+ const resolved = new Map()
111
+ const unresolved = []
112
+ await Promise.all(Array.from(keys).map(async (key) => {
113
+ const value = await getAppConfigValue(appId, key)
114
+ if (value === undefined || value === null || value === '') {
115
+ resolved.set(key, null)
116
+ unresolved.push(key)
117
+ warn(`[resolveManifestSentinels] Manifest sentinel '@resolve:${key}' resolved to null (key unset)`)
118
+ } else {
119
+ resolved.set(key, value)
120
+ }
121
+ }))
122
+
123
+ // Phase 3: rebuild the manifest immutably, substituting sentinels in
124
+ // pages[].config only. Other fields are passed through by reference.
125
+ const out = { ...manifest }
126
+ out.pages = pages.map((page) => {
127
+ if (!isPlainObject(page) || !isPlainObject(page.config)) return page
128
+ return { ...page, config: substituteInTree(page.config, resolved) }
129
+ })
130
+
131
+ return { manifest: out, unresolved }
132
+ }
133
+
134
+ /**
135
+ * Recursively scan a tree, accumulating every sentinel key into the
136
+ * provided `keys` Set. Plain objects + arrays are descended; primitive
137
+ * leaves are checked against the sentinel pattern.
138
+ *
139
+ * @param {*} node Current tree node (object, array, or primitive).
140
+ * @param {Set<string>} keys Accumulator for unique sentinel keys.
141
+ * @return {void}
142
+ */
143
+ function collectSentinelKeys(node, keys) {
144
+ if (typeof node === 'string') {
145
+ const match = node.match(SENTINEL_PATTERN)
146
+ if (match) keys.add(match[1])
147
+ return
148
+ }
149
+ if (Array.isArray(node)) {
150
+ for (const item of node) collectSentinelKeys(item, keys)
151
+ return
152
+ }
153
+ if (isPlainObject(node)) {
154
+ for (const value of Object.values(node)) collectSentinelKeys(value, keys)
155
+ }
156
+ }
157
+
158
+ /**
159
+ * Recursively rebuild a tree, replacing each fully-matched sentinel
160
+ * with its resolved value. Returns a NEW tree; input is unchanged.
161
+ *
162
+ * @param {*} node Current tree node.
163
+ * @param {Map<string,*>} resolved Map of key → resolved value (or null).
164
+ * @return {*} New tree with sentinels substituted.
165
+ */
166
+ function substituteInTree(node, resolved) {
167
+ if (typeof node === 'string') {
168
+ const match = node.match(SENTINEL_PATTERN)
169
+ if (match && resolved.has(match[1])) {
170
+ return resolved.get(match[1])
171
+ }
172
+ return node
173
+ }
174
+ if (Array.isArray(node)) {
175
+ return node.map((item) => substituteInTree(item, resolved))
176
+ }
177
+ if (isPlainObject(node)) {
178
+ const out = {}
179
+ for (const [key, value] of Object.entries(node)) {
180
+ out[key] = substituteInTree(value, resolved)
181
+ }
182
+ return out
183
+ }
184
+ return node
185
+ }
186
+
187
+ /**
188
+ * Default `getAppConfigValue` implementation: consult
189
+ * `@nextcloud/initial-state` first (zero-network), fall back to a
190
+ * runtime fetch with per-(appId, key) caching for the page lifetime.
191
+ *
192
+ * Returns `null` when neither source resolves a value. Network / 4xx
193
+ * errors are swallowed silently — the resolver downgrades to "key
194
+ * unset" in that case (consistent with the silent-fallback pattern in
195
+ * `useAppManifest`'s backend-merge step).
196
+ *
197
+ * @param {string} appId Nextcloud app ID.
198
+ * @param {string} key IAppConfig key (already validated as
199
+ * lowercase + alphanumeric + `_-` by the sentinel regex).
200
+ * @return {Promise<*>} Resolved value or `null` when unset.
201
+ */
202
+ async function defaultGetAppConfigValue(appId, key) {
203
+ const cacheKey = `${appId}::${key}`
204
+ if (resolveCache.has(cacheKey)) {
205
+ return resolveCache.get(cacheKey)
206
+ }
207
+ const promise = (async () => {
208
+ // Step 1: initial-state — synchronous, zero-network.
209
+ const initial = readInitialState(appId, key)
210
+ if (initial !== undefined && initial !== null && initial !== '') {
211
+ return initial
212
+ }
213
+ // Step 2: runtime fetch — silent fallback on any error.
214
+ try {
215
+ const { default: axios } = await import('@nextcloud/axios')
216
+ const { generateUrl } = await import('@nextcloud/router')
217
+ const url = generateUrl(`/apps/${appId}/api/configs/${key}`)
218
+ const response = await axios.get(url)
219
+ if (response && response.status === 200 && response.data !== undefined) {
220
+ const data = response.data
221
+ // API may return either a raw scalar or `{ value: ... }`.
222
+ if (isPlainObject(data) && 'value' in data) return data.value
223
+ return data
224
+ }
225
+ } catch (e) {
226
+ // Silent — caller treats as "unset".
227
+ }
228
+ return null
229
+ })()
230
+ resolveCache.set(cacheKey, promise)
231
+ return promise
232
+ }
233
+
234
+ /**
235
+ * Read a key from `@nextcloud/initial-state`. Looks up the conventional
236
+ * `app-{appId}-{key}` slot. Returns `undefined` when the package is not
237
+ * installed (e.g. older host) or the key is not provisioned.
238
+ *
239
+ * @param {string} appId Nextcloud app ID.
240
+ * @param {string} key IAppConfig key.
241
+ * @return {*} Provisioned value or `undefined`.
242
+ */
243
+ function readInitialState(appId, key) {
244
+ try {
245
+ // `@nextcloud/initial-state` is an optional peer; the host page
246
+ // may not provision the slot at all. We resolve via require so
247
+ // jest mocks the import; bundle-side, the package is treeshaken
248
+ // when no caller pulls it in.
249
+ // eslint-disable-next-line global-require, import/no-unresolved, n/no-extraneous-require
250
+ const mod = require('@nextcloud/initial-state')
251
+ if (typeof mod.loadState === 'function') {
252
+ return mod.loadState(appId, key, undefined)
253
+ }
254
+ } catch (e) {
255
+ // Package not installed or no slot provisioned — fall through.
256
+ }
257
+ return undefined
258
+ }
259
+
260
+ /**
261
+ * Type guard — true when value is a plain (non-array, non-null) object.
262
+ *
263
+ * @param {*} value Candidate.
264
+ * @return {boolean} True when value is a plain object.
265
+ */
266
+ function isPlainObject(value) {
267
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
268
+ }
@@ -1,3 +1,26 @@
1
+ /**
2
+ * Pattern matching the `manifest-resolve-sentinel` capability's
3
+ * sentinel — `@resolve:<key>` where `<key>` is lowercase alphanumeric
4
+ * with `_` / `-` separators. The full string IS the sentinel; partial
5
+ * substitution like `prefix-@resolve:foo` is NOT supported and is left
6
+ * as a plain string for downstream renderers.
7
+ *
8
+ * Build-time validation accepts this pattern as a valid `string` for
9
+ * any `string`-typed field UNDER `pages[].config`, regardless of any
10
+ * narrower per-field constraint. Other paths reject it explicitly.
11
+ */
12
+ const SENTINEL_PATTERN = /^@resolve:[a-z][a-z0-9_-]*$/
13
+
14
+ /**
15
+ * Test whether a string is a manifest `@resolve:` sentinel.
16
+ *
17
+ * @param {*} value Candidate value.
18
+ * @return {boolean} True when the value is a fully-matched sentinel.
19
+ */
20
+ function isSentinel(value) {
21
+ return typeof value === 'string' && SENTINEL_PATTERN.test(value)
22
+ }
23
+
1
24
  /**
2
25
  * Validate a manifest object against the manifest JSON Schema.
3
26
  *
@@ -15,6 +38,13 @@
15
38
  * - `pages[].component` is required when `type` is "custom".
16
39
  * - Per-type `config` shape rules for the built-in types `logs`,
17
40
  * `settings`, `chat`, `files` (REQ from manifest-page-type-extensions).
41
+ * - The `manifest-resolve-sentinel` sentinel `@resolve:<key>` is
42
+ * permissively accepted under `pages[].config.*` and explicitly
43
+ * REJECTED in `version`, `dependencies[]`, `menu[].route`,
44
+ * `menu[].id`, `pages[].id`, `pages[].route`, `pages[].component`,
45
+ * `pages[].headerComponent`, `pages[].actionsComponent`,
46
+ * `pages[].slots.*` — those are router invariants or registry
47
+ * keys.
18
48
  *
19
49
  * The richer schema constraints (`additionalProperties: false`, `format`
20
50
  * URI, etc.) are enforced by the BE / hydra CI validators that consume
@@ -42,6 +72,10 @@ export function validateManifest(manifest, options = {}) {
42
72
 
43
73
  if (typeof manifest.version !== 'string') {
44
74
  errors.push('/version must be a string')
75
+ } else if (isSentinel(manifest.version)) {
76
+ // `manifest-resolve-sentinel` REQ-MRS-004: sentinel is a router /
77
+ // registry invariant violation when used here.
78
+ errors.push(`/version "${manifest.version}" must not be a @resolve: sentinel (sentinels are only valid under pages[].config.*)`)
45
79
  } else if (!versionPattern.test(manifest.version)) {
46
80
  errors.push(`/version "${manifest.version}" must match semver pattern`)
47
81
  }
@@ -54,8 +88,15 @@ export function validateManifest(manifest, options = {}) {
54
88
  errors.push(`/menu/${index} must be an object`)
55
89
  return
56
90
  }
57
- if (typeof item.id !== 'string') errors.push(`/menu/${index}/id must be a string`)
91
+ if (typeof item.id !== 'string') {
92
+ errors.push(`/menu/${index}/id must be a string`)
93
+ } else if (isSentinel(item.id)) {
94
+ errors.push(`/menu/${index}/id must not be a @resolve: sentinel (sentinels are only valid under pages[].config.*)`)
95
+ }
58
96
  if (typeof item.label !== 'string') errors.push(`/menu/${index}/label must be a string`)
97
+ if (item.route !== undefined && isSentinel(item.route)) {
98
+ errors.push(`/menu/${index}/route must not be a @resolve: sentinel (sentinels are only valid under pages[].config.*)`)
99
+ }
59
100
  if (item.children !== undefined && !Array.isArray(item.children)) {
60
101
  errors.push(`/menu/${index}/children must be an array`)
61
102
  }
@@ -77,12 +118,18 @@ export function validateManifest(manifest, options = {}) {
77
118
  }
78
119
  if (typeof page.id !== 'string') {
79
120
  errors.push(`/pages/${index}/id must be a string`)
121
+ } else if (isSentinel(page.id)) {
122
+ errors.push(`/pages/${index}/id must not be a @resolve: sentinel (sentinels are only valid under pages[].config.*)`)
80
123
  } else if (seenIds.has(page.id)) {
81
124
  errors.push(`/pages/${index}/id "${page.id}" must be unique within pages[]`)
82
125
  } else {
83
126
  seenIds.add(page.id)
84
127
  }
85
- if (typeof page.route !== 'string') errors.push(`/pages/${index}/route must be a string`)
128
+ if (typeof page.route !== 'string') {
129
+ errors.push(`/pages/${index}/route must be a string`)
130
+ } else if (isSentinel(page.route)) {
131
+ errors.push(`/pages/${index}/route must not be a @resolve: sentinel (sentinels are only valid under pages[].config.*)`)
132
+ }
86
133
  if (typeof page.title !== 'string') errors.push(`/pages/${index}/title must be a string`)
87
134
  if (typeof page.type !== 'string' || page.type.length === 0) {
88
135
  errors.push(`/pages/${index}/type must be a non-empty string`)
@@ -92,6 +139,25 @@ export function validateManifest(manifest, options = {}) {
92
139
  if (page.type === 'custom' && typeof page.component !== 'string') {
93
140
  errors.push(`/pages/${index}/component is required when type is "custom"`)
94
141
  }
142
+ // `manifest-resolve-sentinel` REQ-MRS-004: registry-key
143
+ // fields cannot be dynamic — they resolve at module-load
144
+ // time against `customComponents`, before the loader runs.
145
+ if (page.component !== undefined && isSentinel(page.component)) {
146
+ errors.push(`/pages/${index}/component must not be a @resolve: sentinel (sentinels are only valid under pages[].config.*)`)
147
+ }
148
+ if (page.headerComponent !== undefined && isSentinel(page.headerComponent)) {
149
+ errors.push(`/pages/${index}/headerComponent must not be a @resolve: sentinel (sentinels are only valid under pages[].config.*)`)
150
+ }
151
+ if (page.actionsComponent !== undefined && isSentinel(page.actionsComponent)) {
152
+ errors.push(`/pages/${index}/actionsComponent must not be a @resolve: sentinel (sentinels are only valid under pages[].config.*)`)
153
+ }
154
+ if (isPlainObject(page.slots)) {
155
+ for (const [slotName, slotValue] of Object.entries(page.slots)) {
156
+ if (isSentinel(slotValue)) {
157
+ errors.push(`/pages/${index}/slots/${slotName} must not be a @resolve: sentinel (sentinels are only valid under pages[].config.*)`)
158
+ }
159
+ }
160
+ }
95
161
 
96
162
  // Per-type config-shape validation for built-in extended types.
97
163
  // (`manifest-page-type-extensions` spec — covers logs/settings/chat/files.)
@@ -115,6 +181,8 @@ export function validateManifest(manifest, options = {}) {
115
181
  manifest.dependencies.forEach((dep, index) => {
116
182
  if (typeof dep !== 'string') {
117
183
  errors.push(`/dependencies/${index} must be a string`)
184
+ } else if (isSentinel(dep)) {
185
+ errors.push(`/dependencies/${index} must not be a @resolve: sentinel (sentinels are only valid under pages[].config.*)`)
118
186
  }
119
187
  })
120
188
  }
@@ -178,7 +246,70 @@ function validateTypeConfig(page, index, errors) {
178
246
  break
179
247
  }
180
248
  case 'settings': {
181
- if (!cfg || !Array.isArray(cfg.sections)) {
249
+ // `manifest-settings-orchestration` REQ-MSO-1: a settings page
250
+ // MUST declare EXACTLY ONE of `sections` | `tabs`. When both
251
+ // are set, emit the orchestration mutex error. When neither is
252
+ // set, fall through to the legacy `sections required` error
253
+ // (back-compat — REQ-MSO-7 / REQ-MSO-1 last scenario).
254
+ const hasSections = cfg && Array.isArray(cfg.sections)
255
+ const hasTabs = cfg && Array.isArray(cfg.tabs)
256
+
257
+ if (hasSections && hasTabs) {
258
+ errors.push(`${pathSlash}: ${pathBracket}: must declare exactly one of sections | tabs`)
259
+ break
260
+ }
261
+
262
+ if (hasTabs) {
263
+ // `manifest-settings-orchestration` REQ-MSO-2..4: validate
264
+ // the `tabs[]` orchestration shape.
265
+ if (cfg.tabs.length === 0) {
266
+ errors.push(`${pathSlash}/tabs: ${pathBracket}.tabs: must contain at least 1 tab`)
267
+ break
268
+ }
269
+ const seenTabIds = Object.create(null)
270
+ cfg.tabs.forEach((tab, tIndex) => {
271
+ if (!isPlainObject(tab)) {
272
+ errors.push(`${pathSlash}/tabs/${tIndex}: must be an object`)
273
+ return
274
+ }
275
+ if (typeof tab.id !== 'string' || tab.id.length === 0) {
276
+ errors.push(`${pathSlash}/tabs/${tIndex}/id: required, must be a non-empty string`)
277
+ }
278
+ if (typeof tab.label !== 'string' || tab.label.length === 0) {
279
+ errors.push(`${pathSlash}/tabs/${tIndex}/label: required, must be a non-empty string`)
280
+ }
281
+ // REQ-MSO-3: tab IDs must be unique within a page.
282
+ if (typeof tab.id === 'string' && tab.id.length > 0) {
283
+ if (seenTabIds[tab.id]) {
284
+ errors.push(`${pathSlash}/tabs/${tIndex}/id: ${pathBracket}.tabs[${tIndex}].id: duplicate id "${tab.id}" — tab IDs must be unique within a page`)
285
+ }
286
+ seenTabIds[tab.id] = true
287
+ }
288
+ // `tab.sections` MUST be a non-empty array.
289
+ if (!Array.isArray(tab.sections)) {
290
+ errors.push(`${pathSlash}/tabs/${tIndex}/sections: ${pathBracket}.tabs[${tIndex}].sections: required, must be an array`)
291
+ return
292
+ }
293
+ if (tab.sections.length === 0) {
294
+ errors.push(`${pathSlash}/tabs/${tIndex}/sections: ${pathBracket}.tabs[${tIndex}].sections: must contain at least 1 section`)
295
+ return
296
+ }
297
+ // REQ-MSO-4: each tab's sections follow the same rules
298
+ // as the flat case — share the per-section validator.
299
+ tab.sections.forEach((section, sIndex) => {
300
+ validateSettingsSection(
301
+ section,
302
+ `${pathSlash}/tabs/${tIndex}/sections/${sIndex}`,
303
+ `${pathBracket}.tabs[${tIndex}].sections[${sIndex}]`,
304
+ errors,
305
+ )
306
+ })
307
+ })
308
+ break
309
+ }
310
+
311
+ // Flat `sections[]` (existing path — REQ-MSRS-* + back-compat).
312
+ if (!hasSections) {
182
313
  errors.push(`${pathSlash}/sections: ${pathBracket}.sections: required, must be an array`)
183
314
  break
184
315
  }
@@ -187,56 +318,12 @@ function validateTypeConfig(page, index, errors) {
187
318
  break
188
319
  }
189
320
  cfg.sections.forEach((section, sIndex) => {
190
- if (!isPlainObject(section)) {
191
- errors.push(`${pathSlash}/sections/${sIndex}: must be an object`)
192
- return
193
- }
194
- if (typeof section.title !== 'string') {
195
- errors.push(`${pathSlash}/sections/${sIndex}/title: required, must be a string`)
196
- }
197
-
198
- // `manifest-settings-rich-sections` REQ-MSRS-1: each
199
- // section MUST declare exactly one of fields | component
200
- // | widgets. Mixed bodies confuse the renderer + duplicate
201
- // the section chrome; empty bodies render nothing so they
202
- // are a manifest-author bug.
203
- const hasFields = Array.isArray(section.fields)
204
- const hasComponent = typeof section.component === 'string' && section.component.length > 0
205
- const hasWidgets = Array.isArray(section.widgets) && section.widgets.length > 0
206
- const bodyCount = (hasFields ? 1 : 0) + (hasComponent ? 1 : 0) + (hasWidgets ? 1 : 0)
207
-
208
- if (bodyCount !== 1) {
209
- errors.push(`${pathSlash}/sections/${sIndex}: ${pathBracket}.sections[${sIndex}]: must declare exactly one of fields | component | widgets`)
210
- }
211
-
212
- // `widgets` set but not an array (string / object / etc.)
213
- if (section.widgets !== undefined && !Array.isArray(section.widgets)) {
214
- errors.push(`${pathSlash}/sections/${sIndex}/widgets: must be an array when set`)
215
- }
216
-
217
- // `component` set but not a string.
218
- if (section.component !== undefined && typeof section.component !== 'string') {
219
- errors.push(`${pathSlash}/sections/${sIndex}/component: must be a string when set`)
220
- }
221
-
222
- // Per-widget shape rules.
223
- if (hasWidgets) {
224
- section.widgets.forEach((widget, wIndex) => {
225
- if (!isPlainObject(widget)) {
226
- errors.push(`${pathSlash}/sections/${sIndex}/widgets/${wIndex}: must be an object`)
227
- return
228
- }
229
- if (typeof widget.type !== 'string' || widget.type.length === 0) {
230
- errors.push(`${pathSlash}/sections/${sIndex}/widgets/${wIndex}/type: must be a non-empty string`)
231
- }
232
- })
233
- }
234
-
235
- // `manifest-config-refs` REQ-MCR — when fields[] body is
236
- // used, each entry must match the formField $def shape.
237
- if (hasFields) {
238
- validateFieldsArray(section.fields, `${pathSlash}/sections/${sIndex}/fields`, errors)
239
- }
321
+ validateSettingsSection(
322
+ section,
323
+ `${pathSlash}/sections/${sIndex}`,
324
+ `${pathBracket}.sections[${sIndex}]`,
325
+ errors,
326
+ )
240
327
  })
241
328
  break
242
329
  }
@@ -669,6 +756,82 @@ function validateLayoutArray(cfg, pathSlash, pathBracket, errors) {
669
756
  })
670
757
  }
671
758
 
759
+ /**
760
+ * Validate a single `sections[]` entry for `type:"settings"` pages.
761
+ * Shared between the flat `pages[].config.sections[]` path AND the
762
+ * tab-nested `pages[].config.tabs[].sections[]` path
763
+ * (`manifest-settings-orchestration` REQ-MSO-4).
764
+ *
765
+ * Enforces the rich-sections REQ-MSRS-1 mutex (`fields | component |
766
+ * widgets` exactly-one-of) plus per-widget shape rules. The new
767
+ * `widget.type === "component"` discriminator (REQ-MSO-6) requires
768
+ * `componentName: <non-empty string>`.
769
+ *
770
+ * @param {*} section The section under validation
771
+ * @param {string} pathSlash JSON-pointer-style path prefix for errors
772
+ * @param {string} pathBracket Human-readable bracket-path for errors
773
+ * @param {string[]} errors Accumulator
774
+ */
775
+ function validateSettingsSection(section, pathSlash, pathBracket, errors) {
776
+ if (!isPlainObject(section)) {
777
+ errors.push(`${pathSlash}: must be an object`)
778
+ return
779
+ }
780
+ if (typeof section.title !== 'string') {
781
+ errors.push(`${pathSlash}/title: required, must be a string`)
782
+ }
783
+
784
+ // `manifest-settings-rich-sections` REQ-MSRS-1: exactly one of
785
+ // fields | component | widgets.
786
+ const hasFields = Array.isArray(section.fields)
787
+ const hasComponent = typeof section.component === 'string' && section.component.length > 0
788
+ const hasWidgets = Array.isArray(section.widgets) && section.widgets.length > 0
789
+ const bodyCount = (hasFields ? 1 : 0) + (hasComponent ? 1 : 0) + (hasWidgets ? 1 : 0)
790
+
791
+ if (bodyCount !== 1) {
792
+ errors.push(`${pathSlash}: ${pathBracket}: must declare exactly one of fields | component | widgets`)
793
+ }
794
+
795
+ // `widgets` set but not an array (string / object / etc.)
796
+ if (section.widgets !== undefined && !Array.isArray(section.widgets)) {
797
+ errors.push(`${pathSlash}/widgets: must be an array when set`)
798
+ }
799
+
800
+ // `component` set but not a string.
801
+ if (section.component !== undefined && typeof section.component !== 'string') {
802
+ errors.push(`${pathSlash}/component: must be a string when set`)
803
+ }
804
+
805
+ // Per-widget shape rules.
806
+ if (hasWidgets) {
807
+ section.widgets.forEach((widget, wIndex) => {
808
+ if (!isPlainObject(widget)) {
809
+ errors.push(`${pathSlash}/widgets/${wIndex}: must be an object`)
810
+ return
811
+ }
812
+ if (typeof widget.type !== 'string' || widget.type.length === 0) {
813
+ errors.push(`${pathSlash}/widgets/${wIndex}/type: must be a non-empty string`)
814
+ return
815
+ }
816
+ // `manifest-settings-orchestration` REQ-MSO-6: when the
817
+ // discriminator is "component", `componentName` MUST be a
818
+ // non-empty string. Other widget types ignore
819
+ // `componentName`.
820
+ if (widget.type === 'component') {
821
+ if (typeof widget.componentName !== 'string' || widget.componentName.length === 0) {
822
+ errors.push(`${pathSlash}/widgets/${wIndex}/componentName: required when type is "component", must be a non-empty string`)
823
+ }
824
+ }
825
+ })
826
+ }
827
+
828
+ // `manifest-config-refs` REQ-MCR — when fields[] body is used,
829
+ // each entry must match the formField $def shape.
830
+ if (hasFields) {
831
+ validateFieldsArray(section.fields, `${pathSlash}/fields`, errors)
832
+ }
833
+ }
834
+
672
835
  /**
673
836
  * Validate `config.sections[].fields[]` for settings page type
674
837
  * (`manifest-config-refs` REQ-MCR). Each field MUST be an object with