@ossy/app 1.40.2 → 3.0.1

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.
Files changed (47) hide show
  1. package/README.md +49 -12
  2. package/cli/build.task.js +49 -10
  3. package/cli/get-platform-files.task.js +21 -15
  4. package/cli/manifest-plugin.js +340 -48
  5. package/package.json +25 -13
  6. package/runtime/merge-shell-slots.js +148 -0
  7. package/runtime/page-runtime.js +36 -18
  8. package/runtime/resolve-app-slots.js +126 -0
  9. package/src/en.translations.json +8 -0
  10. package/src/manifest/action-id-to-tool-name.js +29 -0
  11. package/src/manifest/build-action-input-schema.js +220 -0
  12. package/src/manifest/build-actions-schema.js +36 -0
  13. package/src/manifest/build-capabilities.js +190 -0
  14. package/src/manifest/build-manifest-summary.js +11 -5
  15. package/src/manifest/extract-task-catalog.js +1 -0
  16. package/src/manifest/resolve-page-layout.js +51 -0
  17. package/src/manifest/serialize-package-definition.js +2 -3
  18. package/src/manifest/template-to-json-schema.js +103 -0
  19. package/src/shell/App.jsx +18 -9
  20. package/src/shell/AppSettings.jsx +17 -0
  21. package/src/shell/DevPagesPanel.jsx +17 -13
  22. package/src/shell/HeaderAuthActions.jsx +49 -0
  23. package/src/shell/LanguageList.jsx +83 -0
  24. package/src/shell/LanguageSelect.jsx +73 -0
  25. package/src/shell/ThemeEditor.jsx +19 -34
  26. package/src/shell/ThemeSelect.jsx +101 -0
  27. package/src/shell/WorkspaceAppSettingsSync.jsx +39 -0
  28. package/src/shell/buildSidebarNav.js +113 -0
  29. package/src/shell/index.js +11 -0
  30. package/src/shell/languageCode.js +31 -0
  31. package/src/shell/patchUserAppSettings.js +10 -0
  32. package/src/shell/resolveEndpoints.js +43 -0
  33. package/src/shell/resolveWorkspaceServices.js +20 -0
  34. package/src/shell/themeEditorStyles.js +49 -0
  35. package/src/shell/useCompactShellLayout.js +24 -0
  36. package/src/shell/useShellWorkspace.js +40 -0
  37. package/src/shell-registry/blank.layout.jsx +8 -0
  38. package/src/shell-registry/default.layout.jsx +101 -0
  39. package/src/shell-registry/footer-default.component.jsx +39 -0
  40. package/src/shell-registry/head-default.component.jsx +17 -0
  41. package/src/shell-registry/header-default.component.jsx +50 -0
  42. package/src/shell-registry/logo-logomark.component.jsx +8 -0
  43. package/src/shell-registry/logo-logotype.component.jsx +15 -0
  44. package/src/shell-registry/minimal.layout.jsx +58 -0
  45. package/src/shell-registry/sidebar-default.component.jsx +281 -0
  46. package/src/sv.translations.json +8 -0
  47. package/runtime/resolve-shell-slots.js +0 -112
@@ -0,0 +1,148 @@
1
+ import { CONTENT_SLOT_NAME, normalizeAppSlotName } from './resolve-app-slots.js'
2
+
3
+ /**
4
+ * @typedef {{ view?: string | null, props: Record<string, unknown> }} ShellSlotSpec
5
+ */
6
+
7
+ /**
8
+ * Coerce a manifest/runtime slot value to {@link ShellSlotSpec}.
9
+ *
10
+ * @param {string | null | ShellSlotSpec | undefined} value
11
+ * @returns {ShellSlotSpec | undefined}
12
+ */
13
+ export function coerceShellSlotSpec (value) {
14
+ if (value === null) return { view: null, props: {} }
15
+ if (typeof value === 'string') {
16
+ const trimmed = value.trim()
17
+ return trimmed ? { view: trimmed, props: {} } : undefined
18
+ }
19
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
20
+ const { view, props: nestedProps, ...rest } = value
21
+ const propBag = {
22
+ ...(nestedProps && typeof nestedProps === 'object' && !Array.isArray(nestedProps) ? nestedProps : {}),
23
+ ...rest,
24
+ }
25
+ /** @type {ShellSlotSpec} */
26
+ const spec = { props: propBag }
27
+ if (view === null) spec.view = null
28
+ else if (typeof view === 'string') {
29
+ const trimmed = view.trim()
30
+ if (trimmed) spec.view = trimmed
31
+ }
32
+ return spec
33
+ }
34
+ return undefined
35
+ }
36
+
37
+ /**
38
+ * Apply one layer onto the accumulated slot spec (layout → app → page).
39
+ *
40
+ * - `null` / `{ view: null }` → unset
41
+ * - string → set view, keep inherited props
42
+ * - `{ …props }` → shallow-merge props, keep inherited view
43
+ * - `{ view, …props }` → set view + merge props
44
+ * - `{}` → no-op
45
+ *
46
+ * @param {ShellSlotSpec | undefined} current
47
+ * @param {string | null | Record<string, unknown> | undefined} value
48
+ * @returns {ShellSlotSpec | undefined}
49
+ */
50
+ export function applyShellSlotLayer (current, value) {
51
+ if (value === null) {
52
+ return { view: null, props: {} }
53
+ }
54
+
55
+ if (typeof value === 'string') {
56
+ const trimmed = value.trim()
57
+ if (!trimmed) return current
58
+ return {
59
+ view: trimmed,
60
+ props: current?.props ? { ...current.props } : {},
61
+ }
62
+ }
63
+
64
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
65
+ if (Object.keys(value).length === 0) {
66
+ return current
67
+ }
68
+
69
+ const { view, props: nestedProps, ...rest } = value
70
+ if (view === null) {
71
+ return { view: null, props: {} }
72
+ }
73
+
74
+ const restProps = {
75
+ ...(nestedProps && typeof nestedProps === 'object' && !Array.isArray(nestedProps) ? nestedProps : {}),
76
+ ...rest,
77
+ }
78
+ const nextProps = Object.keys(restProps).length > 0
79
+ ? { ...(current?.props ?? {}), ...restProps }
80
+ : { ...(current?.props ?? {}) }
81
+
82
+ /** @type {ShellSlotSpec} */
83
+ const next = { props: nextProps }
84
+ if (view !== undefined) {
85
+ if (typeof view === 'string') {
86
+ const trimmed = view.trim()
87
+ if (trimmed) next.view = trimmed
88
+ }
89
+ } else if (current?.view !== undefined) {
90
+ next.view = current.view
91
+ }
92
+
93
+ return next
94
+ }
95
+
96
+ return current
97
+ }
98
+
99
+ /**
100
+ * @param {ShellSlotSpec | string | null | undefined} spec
101
+ * @returns {boolean}
102
+ */
103
+ export function shellSlotUnset (spec) {
104
+ const normalized = typeof spec === 'string' || spec === null ? coerceShellSlotSpec(spec) : spec
105
+ return normalized?.view === null
106
+ }
107
+
108
+ /**
109
+ * @param {ShellSlotSpec | string | null | undefined} spec
110
+ * @returns {string | undefined}
111
+ */
112
+ export function shellSlotViewId (spec) {
113
+ const normalized = typeof spec === 'string' || spec === null ? coerceShellSlotSpec(spec) : spec
114
+ if (!normalized || normalized.view === null) return undefined
115
+ return typeof normalized.view === 'string' ? normalized.view : undefined
116
+ }
117
+
118
+ /**
119
+ * Merge shell slot maps per ADR 0012: layout defaults → app config → page metadata.
120
+ * Page layer wins for both view (when set) and props.
121
+ *
122
+ * @param {Record<string, string | null | Record<string, unknown>> | null | undefined} layoutSlots
123
+ * @param {Record<string, string | null | Record<string, unknown>> | null | undefined} appSlots
124
+ * @param {Record<string, string | null | Record<string, unknown>> | null | undefined} pageSlots
125
+ * @returns {Record<string, ShellSlotSpec>}
126
+ */
127
+ export function mergeShellSlots (layoutSlots, appSlots, pageSlots) {
128
+ /** @type {Record<string, ShellSlotSpec>} */
129
+ const merged = {}
130
+
131
+ const apply = (map) => {
132
+ if (!map || typeof map !== 'object' || Array.isArray(map)) return
133
+ Object.entries(map).forEach(([rawKey, value]) => {
134
+ const key = normalizeAppSlotName(rawKey)
135
+ if (!key || key === CONTENT_SLOT_NAME) return
136
+ const next = applyShellSlotLayer(merged[key], value)
137
+ if (next !== undefined) {
138
+ merged[key] = next
139
+ }
140
+ })
141
+ }
142
+
143
+ apply(layoutSlots)
144
+ apply(appSlots)
145
+ apply(pageSlots)
146
+
147
+ return merged
148
+ }
@@ -1,7 +1,8 @@
1
1
  import { createElement } from 'react'
2
2
  import { pageIdToDocumentTitleKey, resolveMessage } from '@ossy/locale'
3
+ import { Slot } from '@ossy/design-system'
3
4
  import { App } from '../src/shell/App.jsx'
4
- import { resolvePageSlots } from './resolve-shell-slots.js'
5
+ import { resolvePageSlots, CONTENT_SLOT_NAME } from './resolve-app-slots.js'
5
6
 
6
7
  /**
7
8
  * Dynamically imports each component bundle listed in `entries` and returns a
@@ -11,14 +12,17 @@ import { resolvePageSlots } from './resolve-shell-slots.js'
11
12
  * skipped so a broken component never prevents the page from rendering.
12
13
  *
13
14
  * @param {Array<{ id: string, entry: string }>} entries
15
+ * @param {(entry: string) => string} [resolveEntry] — map manifest `/static/…` paths to
16
+ * absolute `file://` URLs on the server; omit in the browser (paths resolve via HTTP).
14
17
  * @returns {Promise<Record<string, import('react').ComponentType>>}
15
18
  */
16
- export async function loadComponents (entries = []) {
19
+ export async function loadComponents (entries = [], resolveEntry) {
17
20
  const map = {}
21
+ const resolve = typeof resolveEntry === 'function' ? resolveEntry : (entry) => entry
18
22
  await Promise.all(
19
23
  entries.map(async ({ id, entry }) => {
20
24
  try {
21
- const mod = await import(entry)
25
+ const mod = await import(resolve(entry))
22
26
  if (mod && mod.default) map[id] = mod.default
23
27
  } catch {
24
28
  // silently skip broken component bundles
@@ -29,10 +33,10 @@ export async function loadComponents (entries = []) {
29
33
  }
30
34
 
31
35
  /**
32
- * Dynamically imports the app's single layout bundle. Layout components cannot
36
+ * Dynamically imports the layout bundle for the current page. Layout components cannot
33
37
  * be serialized into the bootstrap JSON (functions are stripped), so the server
34
- * passes `layoutEntry` and the client loads the same module here. When present,
35
- * every page is wrapped route-specific chrome is decided inside the layout.
38
+ * passes `layoutEntry` and the client loads the same module here. Each page may
39
+ * reference a different layout id (resolved at build time per ADR 0012).
36
40
  *
37
41
  * @param {string | null | undefined} layoutEntry
38
42
  * @returns {Promise<import('react').ComponentType | null>}
@@ -76,20 +80,22 @@ function resolveDocumentTitle (metadata, props) {
76
80
  return metadata.title || props.documentTitle || ''
77
81
  }
78
82
 
79
- function buildTree ({ Component, Layout, metadata, props }) {
83
+ /** Page body is registered on `app:content`; layouts only gate shell chrome around it. */
84
+ function buildTree ({ Layout, metadata, props }) {
80
85
  const lang = props.language || props.defaultLanguage || 'en'
81
- const pageEl = createElement(Component, props)
82
- const contentEl = Layout ? createElement(Layout, props, pageEl) : pageEl
86
+ const contentEl = Layout
87
+ ? createElement(Layout, { shellSlots: props.layoutSlots ?? {}, ...props })
88
+ : createElement(Slot, { view: CONTENT_SLOT_NAME })
83
89
  return createElement(
84
90
  'html',
85
- { lang },
91
+ { lang, style: { height: '100%' } },
86
92
  createElement(
87
93
  'head',
88
94
  null,
89
95
  createElement('meta', { charSet: 'utf-8' }),
90
96
  createElement('title', null, resolveDocumentTitle(metadata, props)),
91
97
  ),
92
- createElement('body', null, createElement(App, props, contentEl)),
98
+ createElement('body', { style: { height: '100%', margin: 0 } }, createElement(App, props, contentEl)),
93
99
  )
94
100
  }
95
101
 
@@ -127,19 +133,26 @@ export function createPageEntry (pageModule, options = {}) {
127
133
  import('node:stream'),
128
134
  ])
129
135
 
130
- const { Layout = null, componentEntries = [], layoutSlots = {}, ...pageProps } = props
131
- const componentsById = await loadComponents(componentEntries)
136
+ const {
137
+ Layout = null,
138
+ componentEntries = [],
139
+ layoutSlots = {},
140
+ resolveComponentEntry,
141
+ ...pageProps
142
+ } = props
143
+ const componentsById = await loadComponents(componentEntries, resolveComponentEntry)
144
+ const PageContent = (slotProps = {}) => createElement(Component, { ...pageProps, ...slotProps })
132
145
  const components = resolvePageSlots({
133
146
  layoutSlots,
134
147
  componentsById,
135
- pageComponent: Component,
148
+ pageComponent: PageContent,
136
149
  })
137
- const tree = buildTree({ Component, Layout, metadata, props: { ...pageProps, components } })
150
+ const tree = buildTree({ Layout, metadata, props: { ...pageProps, components, layoutSlots } })
138
151
  const bootstrapUrl = toBootstrapUrl(entryUrl)
139
152
  const bootstrapModules = bootstrapUrl ? [bootstrapUrl] : []
140
153
  // Layout is a React component — JSON.stringify drops functions, so hydration
141
154
  // would render without the shell unless we pass the serializable entry URL.
142
- const { Layout: _layout, ...bootstrapProps } = props
155
+ const { Layout: _layout, resolveComponentEntry: _resolveComponentEntry, ...bootstrapProps } = props
143
156
  const bootstrapScriptContent =
144
157
  'window.__OSSY__=' + escapeBootstrapJson(JSON.stringify(bootstrapProps ?? {}))
145
158
 
@@ -175,12 +188,17 @@ export function createPageEntry (pageModule, options = {}) {
175
188
  loadComponents(componentEntries),
176
189
  loadLayout(layoutEntry),
177
190
  ]).then(([{ hydrateRoot }, componentsById, Layout]) => {
191
+ const PageContent = (slotProps = {}) => createElement(Component, { ...pageProps, ...slotProps })
178
192
  const components = resolvePageSlots({
179
193
  layoutSlots,
180
194
  componentsById,
181
- pageComponent: Component,
195
+ pageComponent: PageContent,
182
196
  })
183
- hydrateRoot(document, buildTree({ Component, Layout, metadata, props: { ...pageProps, components } }))
197
+ hydrateRoot(document, buildTree({
198
+ Layout,
199
+ metadata,
200
+ props: { ...pageProps, components, layoutSlots },
201
+ }))
184
202
  }).catch((err) => {
185
203
  if (typeof console !== 'undefined' && typeof console.error === 'function') {
186
204
  console.error('[@ossy/app][page-runtime] Hydration failed:', err)
@@ -0,0 +1,126 @@
1
+ import { createElement } from 'react'
2
+ import { coerceShellSlotSpec, shellSlotViewId } from './merge-shell-slots.js'
3
+
4
+ /** Canonical app chrome slot names (namespaced). App layout maps chrome only — not content. */
5
+ export const APP_SLOT_NAMES = [
6
+ 'app:head',
7
+ 'app:header',
8
+ 'app:sidebar',
9
+ 'app:toolbar',
10
+ 'app:notifications',
11
+ 'app:system-messages',
12
+ 'app:footer',
13
+ ]
14
+
15
+ /** Platform-owned slot filled with the current route page component. */
16
+ export const CONTENT_SLOT_NAME = 'app:content'
17
+
18
+ const APP_SLOT_PREFIX = 'app'
19
+
20
+ /** Bare app region → namespaced key (legacy fallback for app components only). */
21
+ const BARE_APP_REGION = {
22
+ head: 'app:head',
23
+ header: 'app:header',
24
+ sidebar: 'app:sidebar',
25
+ toolbar: 'app:toolbar',
26
+ notifications: 'app:notifications',
27
+ 'system-messages': 'app:system-messages',
28
+ footer: 'app:footer',
29
+ content: CONTENT_SLOT_NAME,
30
+ }
31
+
32
+ /**
33
+ * @param {import('react').ComponentType<any>} Component
34
+ * @param {Record<string, unknown>} defaultProps
35
+ * @returns {import('react').ComponentType<any>}
36
+ */
37
+ function withShellSlotProps (Component, defaultProps) {
38
+ function ShellSlotTarget (props) {
39
+ return createElement(Component, { ...defaultProps, ...props })
40
+ }
41
+ ShellSlotTarget.displayName = `ShellSlot(${Component.displayName || Component.name || 'Component'})`
42
+ return ShellSlotTarget
43
+ }
44
+
45
+ /**
46
+ * Normalize a slot map key to the canonical namespaced form when it is a bare app region.
47
+ *
48
+ * @param {string} slotName
49
+ * @returns {string}
50
+ */
51
+ export function normalizeAppSlotName (slotName) {
52
+ const key = typeof slotName === 'string' ? slotName.trim() : ''
53
+ return BARE_APP_REGION[key] ?? key
54
+ }
55
+
56
+ /**
57
+ * Build `Record<slotName, Component>` from merged shell slot specs
58
+ * (`slotName → component id + default props`) and components loaded by `metadata.id`.
59
+ *
60
+ * @param {Record<string, string | null | import('./merge-shell-slots.js').ShellSlotSpec | undefined> | null | undefined} layoutSlotsMap
61
+ * @param {Record<string, import('react').ComponentType>} componentsById
62
+ * @returns {Record<string, import('react').ComponentType>}
63
+ */
64
+ export function resolveAppSlots (layoutSlotsMap, componentsById) {
65
+ /** @type {Record<string, import('react').ComponentType>} */
66
+ const resolved = {}
67
+
68
+ const map = layoutSlotsMap && typeof layoutSlotsMap === 'object' ? layoutSlotsMap : {}
69
+ for (const [rawSlot, rawSpec] of Object.entries(map)) {
70
+ const slotName = normalizeAppSlotName(rawSlot)
71
+ if (!slotName || slotName === CONTENT_SLOT_NAME) continue
72
+
73
+ const spec = coerceShellSlotSpec(rawSpec) ?? (
74
+ rawSpec && typeof rawSpec === 'object' && !Array.isArray(rawSpec) ? rawSpec : undefined
75
+ )
76
+
77
+ if (spec?.view === null) {
78
+ resolved[slotName] = null
79
+ continue
80
+ }
81
+
82
+ const componentId = shellSlotViewId(spec ?? rawSpec)
83
+ if (!componentId) continue
84
+
85
+ const Component = componentsById[componentId]
86
+ if (!Component) continue
87
+
88
+ const defaultProps = spec?.props ?? {}
89
+ resolved[slotName] = Object.keys(defaultProps).length > 0
90
+ ? withShellSlotProps(Component, defaultProps)
91
+ : Component
92
+ }
93
+
94
+ for (const slotName of APP_SLOT_NAMES) {
95
+ if (slotName in resolved) continue
96
+ if (componentsById[slotName]) {
97
+ resolved[slotName] = componentsById[slotName]
98
+ continue
99
+ }
100
+ const bare = slotName.slice(`${APP_SLOT_PREFIX}:`.length)
101
+ if (componentsById[bare]) resolved[slotName] = componentsById[bare]
102
+ }
103
+
104
+ return resolved
105
+ }
106
+
107
+ /**
108
+ * Full provider slot map for a page request: app chrome and page content.
109
+ * Feature components register at canonical ADR 0006 ids (`@ossy/…/view|form|…`).
110
+ *
111
+ * @param {{
112
+ * layoutSlots?: Record<string, string | null | import('./merge-shell-slots.js').ShellSlotSpec> | null,
113
+ * componentsById?: Record<string, import('react').ComponentType>,
114
+ * pageComponent?: import('react').ComponentType | null,
115
+ * }} options
116
+ * @returns {Record<string, import('react').ComponentType>}
117
+ */
118
+ export function resolvePageSlots ({ layoutSlots, componentsById = {}, pageComponent = null } = {}) {
119
+ const appSlots = resolveAppSlots(layoutSlots, componentsById)
120
+ /** @type {Record<string, import('react').ComponentType>} */
121
+ const resolved = { ...componentsById, ...appSlots }
122
+ if (pageComponent) {
123
+ resolved[CONTENT_SLOT_NAME] = pageComponent
124
+ }
125
+ return resolved
126
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "app.shell.sidebar.expand": "Expand sidebar",
3
+ "app.shell.sidebar.collapse": "Collapse sidebar",
4
+ "app.shell.header.languagePicker": "Language",
5
+ "app.shell.footer.languages": "Languages",
6
+ "app.shell.header.themeSwitch": "{theme} theme. Switch theme.",
7
+ "app.shell.header.profile": "Profile"
8
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Derive MCP tool name from platform action id.
3
+ *
4
+ * @example
5
+ * actionIdToToolName('@ossy/resources/actions/list') // 'ossy_resources_list'
6
+ *
7
+ * @param {string} actionId
8
+ * @returns {string}
9
+ */
10
+ export function actionIdToToolName (actionId) {
11
+ const parts = actionId.split('/')
12
+ if (parts.length >= 4 && parts[0].startsWith('@') && parts[2] === 'actions') {
13
+ const provider = parts[0].slice(1)
14
+ return `${provider}_${parts[1]}_${parts[3]}`.replace(/-/g, '_')
15
+ }
16
+ return `ossy_${actionId.replace(/^@/, '').replace(/\//g, '_').replace(/-/g, '_')}`
17
+ }
18
+
19
+ /**
20
+ * @param {string} actionId
21
+ * @returns {string}
22
+ */
23
+ export function humanizeActionId (actionId) {
24
+ const intent = actionId.split('/').pop() || actionId
25
+ return intent
26
+ .split('-')
27
+ .map(word => word.charAt(0).toUpperCase() + word.slice(1))
28
+ .join(' ')
29
+ }
@@ -0,0 +1,220 @@
1
+ import { templatesContentOneOf } from './template-to-json-schema.js'
2
+
3
+ const workspaceIdProp = {
4
+ workspaceId: {
5
+ type: 'string',
6
+ description: 'Workspace id (defaults to request workspace header)',
7
+ },
8
+ }
9
+
10
+ /** @type {Record<string, (ctx: { schemas: object[] }) => object>} */
11
+ const ENVELOPES = {
12
+ '@ossy/resources/actions/list': () => ({
13
+ type: 'object',
14
+ properties: {
15
+ ...workspaceIdProp,
16
+ location: { type: 'string', description: 'Folder path, e.g. /test/' },
17
+ query: { type: 'object', description: 'Optional query filters merged with location' },
18
+ },
19
+ }),
20
+
21
+ '@ossy/resources/actions/get': () => ({
22
+ type: 'object',
23
+ required: ['resourceId'],
24
+ properties: {
25
+ ...workspaceIdProp,
26
+ resourceId: { type: 'string' },
27
+ },
28
+ }),
29
+
30
+ '@ossy/resources/actions/search': () => ({
31
+ type: 'object',
32
+ properties: {
33
+ ...workspaceIdProp,
34
+ location: { type: 'string' },
35
+ name: { type: 'string' },
36
+ type: { type: 'string', description: 'Resource template id or mime type' },
37
+ },
38
+ }),
39
+
40
+ '@ossy/resources/actions/create': ({ schemas }) => {
41
+ const contentSchema = templatesContentOneOf(schemas)
42
+ return {
43
+ type: 'object',
44
+ required: ['location', 'name', 'type'],
45
+ properties: {
46
+ ...workspaceIdProp,
47
+ location: { type: 'string', description: 'Parent folder path, e.g. /test/' },
48
+ name: { type: 'string' },
49
+ type: {
50
+ type: 'string',
51
+ description: 'directory | mime type (binary) | registered resource template id',
52
+ },
53
+ size: {
54
+ type: 'number',
55
+ description: 'Byte size — required when type is a mime type (binary upload)',
56
+ },
57
+ ...(contentSchema
58
+ ? {
59
+ content: {
60
+ description: 'Document payload when type is a resource template id',
61
+ ...contentSchema,
62
+ },
63
+ }
64
+ : {}),
65
+ },
66
+ }
67
+ },
68
+
69
+ '@ossy/resources/actions/update-content': ({ schemas }) => {
70
+ const contentSchema = templatesContentOneOf(schemas)
71
+ return {
72
+ type: 'object',
73
+ required: ['resourceId', 'content'],
74
+ properties: {
75
+ ...workspaceIdProp,
76
+ resourceId: { type: 'string' },
77
+ ...(contentSchema
78
+ ? { content: contentSchema }
79
+ : { content: { type: 'object' } }),
80
+ },
81
+ }
82
+ },
83
+
84
+ '@ossy/resources/actions/delete': () => ({
85
+ type: 'object',
86
+ required: ['resourceId'],
87
+ properties: {
88
+ ...workspaceIdProp,
89
+ resourceId: { type: 'string' },
90
+ },
91
+ }),
92
+
93
+ '@ossy/resources/actions/upload-named-version': () => ({
94
+ type: 'object',
95
+ required: ['resourceId', 'namedVersion', 'type', 'size'],
96
+ properties: {
97
+ ...workspaceIdProp,
98
+ resourceId: { type: 'string' },
99
+ namedVersion: { type: 'string', description: 'Version label, e.g. thumbnail' },
100
+ type: { type: 'string', description: 'Mime type' },
101
+ size: { type: 'number' },
102
+ },
103
+ }),
104
+
105
+ '@ossy/resources/actions/update-name': () => ({
106
+ type: 'object',
107
+ required: ['resourceId', 'name'],
108
+ properties: {
109
+ ...workspaceIdProp,
110
+ resourceId: { type: 'string' },
111
+ name: { type: 'string' },
112
+ },
113
+ }),
114
+
115
+ '@ossy/resources/actions/update-location': () => ({
116
+ type: 'object',
117
+ required: ['resourceId', 'target'],
118
+ properties: {
119
+ ...workspaceIdProp,
120
+ resourceId: { type: 'string' },
121
+ target: { type: 'string', description: 'Destination folder path' },
122
+ },
123
+ }),
124
+
125
+ '@ossy/resources/actions/update-access': () => ({
126
+ type: 'object',
127
+ required: ['resourceId', 'access'],
128
+ properties: {
129
+ ...workspaceIdProp,
130
+ resourceId: { type: 'string' },
131
+ access: { type: 'string', enum: ['public', 'workspace', 'restricted'] },
132
+ },
133
+ }),
134
+
135
+ '@ossy/booking/actions/get-services': () => ({
136
+ type: 'object',
137
+ properties: {
138
+ providerSlug: { type: 'string' },
139
+ workspaceId: { type: 'string' },
140
+ includeInactive: { type: 'boolean' },
141
+ includeContact: { type: 'boolean' },
142
+ },
143
+ }),
144
+
145
+ '@ossy/booking/actions/get-available-slots': () => ({
146
+ type: 'object',
147
+ properties: {
148
+ providerSlug: { type: 'string' },
149
+ workspaceId: { type: 'string' },
150
+ serviceId: { type: 'string' },
151
+ duration: { type: 'number', description: 'Slot duration in minutes' },
152
+ from: { type: 'number', description: 'Range start (Unix ms)' },
153
+ to: { type: 'number', description: 'Range end (Unix ms)' },
154
+ },
155
+ }),
156
+
157
+ '@ossy/booking/actions/create': () => ({
158
+ type: 'object',
159
+ required: ['startAt', 'duration', 'clientName', 'clientEmail'],
160
+ properties: {
161
+ providerSlug: { type: 'string' },
162
+ workspaceId: { type: 'string' },
163
+ serviceId: { type: 'string' },
164
+ startAt: { type: 'number', description: 'Slot start (Unix ms)' },
165
+ duration: { type: 'number', description: 'Duration in minutes' },
166
+ clientName: { type: 'string' },
167
+ clientEmail: { type: 'string', format: 'email' },
168
+ clientMessage: { type: 'string' },
169
+ },
170
+ }),
171
+
172
+ '@ossy/workspaces/actions/get-schemas': () => ({
173
+ type: 'object',
174
+ properties: { ...workspaceIdProp },
175
+ }),
176
+
177
+ '@ossy/workspaces/actions/import-schemas': () => ({
178
+ type: 'object',
179
+ required: ['schemas'],
180
+ properties: {
181
+ ...workspaceIdProp,
182
+ schemas: {
183
+ type: 'array',
184
+ items: { type: 'object' },
185
+ description: 'Workspace-imported schema definitions',
186
+ },
187
+ },
188
+ }),
189
+
190
+ '@ossy/users/actions/create-api-token': () => ({
191
+ type: 'object',
192
+ required: ['name', 'description'],
193
+ properties: {
194
+ name: { type: 'string' },
195
+ description: { type: 'string' },
196
+ },
197
+ }),
198
+ }
199
+
200
+ /**
201
+ * @param {string} actionId
202
+ * @param {{ schemas?: object[] }} [ctx]
203
+ * @returns {object}
204
+ */
205
+ export function buildActionInputSchema (actionId, ctx = {}) {
206
+ const schemas = ctx.schemas || []
207
+ const builder = ENVELOPES[actionId]
208
+ if (builder) {
209
+ return builder({ schemas })
210
+ }
211
+
212
+ return {
213
+ type: 'object',
214
+ properties: {
215
+ ...workspaceIdProp,
216
+ payload: { type: 'object', description: 'Action-specific payload' },
217
+ },
218
+ additionalProperties: true,
219
+ }
220
+ }
@@ -0,0 +1,36 @@
1
+ import { buildCapabilities, capabilitiesToActionsSchema } from './build-capabilities.js'
2
+ import { taskIdFromActionId } from '@ossy/schema'
3
+
4
+ /**
5
+ * @param {Array<{ id: string, access?: string, package?: string }>} actions
6
+ * @param {{ schemas?: object[], tasks?: object[], taskCatalog?: object[], taskGraphEdges?: object[], generatedAt?: string }} [options]
7
+ * @returns {{ version: number, generatedAt: string, actions: Array<object> }}
8
+ */
9
+ export function buildActionsSchema (actions = [], options = {}) {
10
+ const capabilities = buildCapabilities(
11
+ {
12
+ actions,
13
+ tasks: options.tasks || actions.flatMap(a => {
14
+ try {
15
+ return [{ id: taskIdFromActionId(a.id) }]
16
+ } catch {
17
+ return []
18
+ }
19
+ }),
20
+ schemas: options.schemas || [],
21
+ taskCatalog: options.taskCatalog || [],
22
+ taskGraphEdges: options.taskGraphEdges || [],
23
+ },
24
+ { generatedAt: options.generatedAt },
25
+ )
26
+ return capabilitiesToActionsSchema(capabilities, actions)
27
+ }
28
+
29
+ export {
30
+ buildCapabilities,
31
+ buildCapabilitiesGraph,
32
+ capabilitiesTaskTopologyResource,
33
+ capabilitiesToActionsSchema,
34
+ projectTaskForCapabilities,
35
+ TASK_TOPOLOGY_RESOURCE_URI,
36
+ } from './build-capabilities.js'