@conduction/nextcloud-vue 1.0.0-beta.21 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@conduction/nextcloud-vue",
3
- "version": "1.0.0-beta.21",
3
+ "version": "1.0.0-beta.22",
4
4
  "description": "Shared Vue component library for Conduction Nextcloud apps — complements @nextcloud/vue with higher-level components, OpenRegister integration, and NL Design System support",
5
5
  "license": "EUPL-1.2",
6
6
  "author": "Conduction B.V. <info@conduction.nl>",
@@ -2,19 +2,26 @@ import { ref } from 'vue'
2
2
  import axios from '@nextcloud/axios'
3
3
  import { generateUrl } from '@nextcloud/router'
4
4
  import { validateManifest } from '../utils/validateManifest.js'
5
+ import { resolveManifestSentinels } from '../utils/resolveManifestSentinels.js'
5
6
 
6
7
  /**
7
- * Composable that loads and validates a Conduction app manifest.
8
+ * Composable that loads, resolves, and validates a Conduction app manifest.
8
9
  *
9
- * The composable implements the three-phase flow specified in
10
- * REQ-JMR-002 of the json-manifest-renderer capability:
10
+ * The composable implements the four-phase flow specified in
11
+ * REQ-JMR-002 of the json-manifest-renderer capability + the
12
+ * substitution step from the `manifest-resolve-sentinel` capability:
11
13
  *
12
14
  * 1. Synchronous bundled load — `bundledManifest` is the immediate value.
13
15
  * 2. Async backend merge — fetches `/index.php/apps/{appId}/api/manifest`
14
16
  * and deep-merges any 200 response over the bundled manifest. 4xx /
15
17
  * 5xx / network errors are silently ignored so apps work without a
16
18
  * backend endpoint.
17
- * 3. Validationthe merged result is validated against
19
+ * 3. Sentinel resolution `@resolve:<key>` strings under
20
+ * `pages[].config` are substituted with `IAppConfig` values via the
21
+ * `resolveManifestSentinels` utility (see its module docs for the
22
+ * resolution source chain). Unresolved keys surface on the
23
+ * returned `unresolvedSentinels` ref.
24
+ * 4. Validation — the resolved result is validated against
18
25
  * `app-manifest.schema.json`. On failure, the bundled manifest is
19
26
  * kept and a `console.warn` is emitted with the error list.
20
27
  *
@@ -22,7 +29,8 @@ import { validateManifest } from '../utils/validateManifest.js'
22
29
  * can hot-swap the manifest without a page reload.
23
30
  *
24
31
  * @param {string} appId Nextcloud app ID. Used to build the default
25
- * backend endpoint URL via `@nextcloud/router`.
32
+ * backend endpoint URL via `@nextcloud/router` and to scope
33
+ * IAppConfig lookups for `@resolve:<key>` sentinels.
26
34
  * @param {object} bundledManifest The manifest shipped with the app (the
27
35
  * default value, available synchronously).
28
36
  * @param {object} [options] Configuration options.
@@ -32,7 +40,10 @@ import { validateManifest } from '../utils/validateManifest.js'
32
40
  * return a promise resolving to `{ status: number, data: object }`.
33
41
  * Defaults to `axios.get` from `@nextcloud/axios` (which inherits the
34
42
  * Nextcloud CSRF token automatically).
35
- * @return {{ manifest: import('vue').Ref<object>, isLoading: import('vue').Ref<boolean>, validationErrors: import('vue').Ref<string[]|null> }}
43
+ * @param {Function} [options.getAppConfigValue] Override the
44
+ * IAppConfig resolver consumed by `resolveManifestSentinels`. Useful
45
+ * for tests that want to mount a fixture-driven config map.
46
+ * @return {{ manifest: import('vue').Ref<object>, isLoading: import('vue').Ref<boolean>, validationErrors: import('vue').Ref<string[]|null>, unresolvedSentinels: import('vue').Ref<string[]> }}
36
47
  *
37
48
  * @example Basic usage (Composition API)
38
49
  * const { manifest, isLoading } = useAppManifest('decidesk', bundled)
@@ -49,11 +60,17 @@ import { validateManifest } from '../utils/validateManifest.js'
49
60
  * endpoint: '/custom/manifest/url',
50
61
  * fetcher: (url) => Promise.resolve({ status: 200, data: { ... } }),
51
62
  * })
63
+ *
64
+ * @example With sentinel resolution + admin warning surface
65
+ * const { manifest, unresolvedSentinels } = useAppManifest('softwarecatalog', bundled)
66
+ * // unresolvedSentinels.value is e.g. ['voorzieningen_register']
67
+ * // when that IAppConfig key is unset on the tenant.
52
68
  */
53
69
  export function useAppManifest(appId, bundledManifest, options = {}) {
54
70
  const manifest = ref(bundledManifest)
55
71
  const isLoading = ref(true)
56
72
  const validationErrors = ref(null)
73
+ const unresolvedSentinels = ref([])
57
74
 
58
75
  const endpoint = options.endpoint ?? generateUrl(`/apps/${appId}/api/manifest`)
59
76
  const fetcher = options.fetcher ?? ((url) => axios.get(url))
@@ -65,7 +82,18 @@ export function useAppManifest(appId, bundledManifest, options = {}) {
65
82
  return
66
83
  }
67
84
  const merged = deepMerge(bundledManifest, response.data)
68
- const result = validateManifest(merged)
85
+
86
+ // Sentinel resolution runs BEFORE validation per
87
+ // REQ-MRS-002: the validator MUST NEVER observe an
88
+ // unresolved sentinel at runtime. Resolution failures
89
+ // (unset IAppConfig keys) substitute null and accumulate
90
+ // on `unresolvedSentinels`; they do NOT block validation.
91
+ const { manifest: resolved, unresolved } = await resolveManifestSentinels(merged, appId, {
92
+ getAppConfigValue: options.getAppConfigValue,
93
+ })
94
+ unresolvedSentinels.value = unresolved
95
+
96
+ const result = validateManifest(resolved)
69
97
  if (!result.valid) {
70
98
  validationErrors.value = result.errors
71
99
  // eslint-disable-next-line no-console
@@ -75,7 +103,7 @@ export function useAppManifest(appId, bundledManifest, options = {}) {
75
103
  )
76
104
  return
77
105
  }
78
- manifest.value = merged
106
+ manifest.value = resolved
79
107
  } catch (err) {
80
108
  // Silent fallback on 404, network errors, non-200 responses.
81
109
  // Apps without a backend endpoint should keep working.
@@ -84,7 +112,7 @@ export function useAppManifest(appId, bundledManifest, options = {}) {
84
112
  }
85
113
  })()
86
114
 
87
- return { manifest, isLoading, validationErrors }
115
+ return { manifest, isLoading, validationErrors, unresolvedSentinels }
88
116
  }
89
117
 
90
118
  /**
package/src/index.js CHANGED
@@ -119,4 +119,5 @@ export { registerTranslations } from './l10n/index.js'
119
119
  export { buildHeaders, buildQueryString, parseResponseError, networkError, genericError } from './utils/index.js'
120
120
  export { columnsFromSchema, formatValue, filtersFromSchema, fieldsFromSchema, validateValue } from './utils/index.js'
121
121
  export { validateManifest } from './utils/validateManifest.js'
122
+ export { resolveManifestSentinels, clearResolveCache } from './utils/resolveManifestSentinels.js'
122
123
  export { filterWidgetsByVisibility, isWidgetVisible, getCurrentUserId, getCurrentUserGroups, resetVisibilityCache } from './utils/index.js'
@@ -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
  }