@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/dist/nextcloud-vue.cjs.js +404 -38
- package/dist/nextcloud-vue.cjs.js.map +1 -1
- package/dist/nextcloud-vue.esm.js +403 -39
- package/dist/nextcloud-vue.esm.js.map +1 -1
- package/package.json +1 -1
- package/src/composables/useAppManifest.js +37 -9
- package/src/index.js +1 -0
- package/src/utils/resolveManifestSentinels.js +268 -0
- package/src/utils/validateManifest.js +70 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@conduction/nextcloud-vue",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
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
|
|
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.
|
|
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
|
-
* @
|
|
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
|
-
|
|
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 =
|
|
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')
|
|
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')
|
|
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
|
}
|