@ossy/platform 1.39.2 → 1.39.3

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 (65) hide show
  1. package/README.md +21 -17
  2. package/package.json +20 -11
  3. package/src/Definition.js +1 -0
  4. package/src/PlatformShell.jsx +10 -10
  5. package/src/actions/action.service.js +79 -6
  6. package/src/audit/action-invocation-service.js +144 -0
  7. package/src/audit/action-invocation.aggregate.js +59 -0
  8. package/src/audit/audit-helpers.js +61 -0
  9. package/src/audit/detect-channel.js +19 -0
  10. package/src/audit/list-task-runs.action.js +5 -0
  11. package/src/audit/list-task-runs.task.js +17 -0
  12. package/src/audit/task-run-list.aggregate.js +78 -0
  13. package/src/audit/task-run-service.js +193 -0
  14. package/src/audit/task-run.aggregate.js +60 -0
  15. package/src/auth/action-scopes.js +3 -0
  16. package/src/capability-schemas/action-capability.schema.js +1 -0
  17. package/src/capability-schemas/action-meta.schema.js +1 -0
  18. package/src/capability-schemas/component-capability.schema.js +1 -0
  19. package/src/capability-schemas/page-capability.schema.js +1 -0
  20. package/src/capability-schemas/page-meta.schema.js +1 -0
  21. package/src/capability-schemas/task-capability.schema.js +1 -0
  22. package/src/capability-schemas/task-meta.schema.js +1 -0
  23. package/src/capability-schemas/task-output.schema.js +1 -0
  24. package/src/capability-schemas/task-trigger-authoring.schema.js +1 -0
  25. package/src/capability-schemas/task-trigger.schema.js +1 -0
  26. package/src/directory.schema.js +9 -0
  27. package/src/entitlements/action-entitlement.js +69 -0
  28. package/src/file.schema.js +13 -0
  29. package/src/index.js +13 -3
  30. package/src/mcp/create-ossy-mcp-server.js +97 -0
  31. package/src/mcp/json-schema-to-zod.js +64 -0
  32. package/src/mcp/mount-ossy-mcp.js +72 -0
  33. package/src/mcp/mount-platform-mcp.js +101 -0
  34. package/src/mcp/upload-file-tool.js +62 -0
  35. package/src/metering/metering-service.js +92 -0
  36. package/src/{platform-config.resource.js → platform-config.schema.js} +1 -1
  37. package/src/proxy-internal.js +13 -16
  38. package/src/push/mount-push-sse.js +43 -0
  39. package/src/resources/index.js +3 -2
  40. package/src/resources/schema.registry.js +26 -0
  41. package/src/resources/schema.service.js +54 -0
  42. package/src/resources/schema.validation.js +90 -0
  43. package/src/runtime.js +4 -1
  44. package/src/schema-ids.js +9 -0
  45. package/src/server.js +121 -20
  46. package/src/storage/filesystem-storage.client.js +109 -0
  47. package/src/storage/local-storage.client.js +2 -65
  48. package/src/storage/resource-read-url.js +36 -0
  49. package/src/storage/resource-read-url.spec.js +22 -0
  50. package/src/storage/s3-storage.client.js +102 -0
  51. package/src/storage/s3.client.js +27 -23
  52. package/src/storage/storage-keys.js +37 -0
  53. package/src/storage/storage-keys.spec.js +16 -0
  54. package/src/storage/storage.client.js +52 -8
  55. package/src/storage/storage.integration.js +40 -0
  56. package/src/tasks/change-stream.js +9 -2
  57. package/src/tasks/task-service.js +211 -34
  58. package/src/tasks/task-service.spec.js +187 -0
  59. package/src/test/e2e.util.js +18 -39
  60. package/src/test/flow-runner.js +476 -0
  61. package/src/test/test.util.js +30 -29
  62. package/src/user-app-settings.js +44 -0
  63. package/src/users.middleware.js +14 -2
  64. package/src/resources/resource-template.registry.js +0 -29
  65. package/src/resources/resource-template.validation.js +0 -232
@@ -0,0 +1,72 @@
1
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
2
+ import { createOssyMcpServer } from './create-ossy-mcp-server.js'
3
+
4
+ /**
5
+ * @param {import('express').Express} app
6
+ * @param {{
7
+ * capabilities: object,
8
+ * invokeAction: (actionId: string, payload: object, req: import('express').Request) => Promise<unknown>,
9
+ * requireAuth?: (req: import('express').Request, res: import('express').Response) => boolean,
10
+ * customTools?: Array<object>,
11
+ * path?: string,
12
+ * capabilitiesPath?: string,
13
+ * }} options
14
+ */
15
+ export function mountOssyMcp ({ app, capabilities, invokeAction, requireAuth, customTools, path = '/mcp', capabilitiesPath = '/capabilities.json' }) {
16
+ app.get(capabilitiesPath, (req, res) => {
17
+ if (requireAuth && !requireAuth(req, res)) return
18
+ res.json(capabilities)
19
+ })
20
+
21
+ const handleMcpPost = async (req, res) => {
22
+ if (requireAuth && !requireAuth(req, res)) return
23
+
24
+ const server = createOssyMcpServer({
25
+ capabilities,
26
+ customTools: customTools?.map((tool) => ({
27
+ ...tool,
28
+ handler: (args, extra) => tool.handler(args, req, extra),
29
+ })),
30
+ invokeAction: (actionId, payload) => invokeAction(actionId, payload, req),
31
+ })
32
+
33
+ const transport = new StreamableHTTPServerTransport({
34
+ sessionIdGenerator: undefined,
35
+ })
36
+
37
+ try {
38
+ await server.connect(transport)
39
+ await transport.handleRequest(req, res, req.body)
40
+ res.on('close', () => {
41
+ transport.close()
42
+ server.close()
43
+ })
44
+ } catch (err) {
45
+ if (!res.headersSent) {
46
+ res.status(500).json({
47
+ jsonrpc: '2.0',
48
+ error: { code: -32603, message: err?.message || 'Internal error' },
49
+ id: null,
50
+ })
51
+ }
52
+ }
53
+ }
54
+
55
+ app.post(path, handleMcpPost)
56
+
57
+ app.get(path, (_req, res) => {
58
+ res.status(405).json({
59
+ jsonrpc: '2.0',
60
+ error: { code: -32000, message: 'Method not allowed.' },
61
+ id: null,
62
+ })
63
+ })
64
+
65
+ app.delete(path, (_req, res) => {
66
+ res.status(405).json({
67
+ jsonrpc: '2.0',
68
+ error: { code: -32000, message: 'Method not allowed.' },
69
+ id: null,
70
+ })
71
+ })
72
+ }
@@ -0,0 +1,101 @@
1
+ import { buildCapabilities } from '@ossy/app/manifest/build-capabilities'
2
+ import { mountOssyMcp } from './mount-ossy-mcp.js'
3
+ import { uploadFileToolHandler, UPLOAD_FILE_TOOL } from './upload-file-tool.js'
4
+ import { ActionService } from '../actions/action.service.js'
5
+ import { TaskService } from '../tasks/task-service.js'
6
+ import { IntegrationService } from '../integration.service.js'
7
+ import { createLogger } from '@ossy/observability'
8
+ import { assertActionEntitled } from '../entitlements/action-entitlement.js'
9
+
10
+ /**
11
+ * @param {import('express').Express} app
12
+ * @param {{
13
+ * manifest: object,
14
+ * capabilities?: object,
15
+ * log?: import('@ossy/observability').Logger,
16
+ * actionEntitlementIndex?: Map<string, { packageName: string, entitlementRequired: boolean }>,
17
+ * loadWorkspaceForEntitlements?: (workspaceId: string) => Promise<{ services?: Record<string, unknown> } | null>,
18
+ * }} options
19
+ */
20
+ export function mountPlatformMcp (app, {
21
+ manifest,
22
+ capabilities,
23
+ log = createLogger('platform/mcp'),
24
+ actionEntitlementIndex,
25
+ loadWorkspaceForEntitlements,
26
+ }) {
27
+ const resolvedCapabilities = capabilities ?? buildCapabilities({
28
+ actions: manifest.actions,
29
+ tasks: manifest.tasks,
30
+ schemas: manifest.schemas,
31
+ taskCatalog: manifest.taskCatalog,
32
+ taskGraphEdges: manifest.taskGraphEdges,
33
+ })
34
+
35
+ const requireAuth = (req, res) => {
36
+ if (!req.userId || req.userId === 'anonymous') {
37
+ res.status(401).json({ error: 'Unauthorized — MCP requires API token or session auth' })
38
+ return false
39
+ }
40
+ return true
41
+ }
42
+
43
+ const invokePlatformAction = async (actionId, payload, req) => {
44
+ const action = ActionService.get(actionId)
45
+ if (!action) {
46
+ throw Object.assign(new Error(`Action not found: ${actionId}`), { status: 404 })
47
+ }
48
+
49
+ if (action.access === 'authenticated' && (!req?.userId || req.userId === 'anonymous')) {
50
+ throw Object.assign(new Error('Unauthorized'), { status: 401 })
51
+ }
52
+ if (action.access === 'workspace' && !req?.workspaceId) {
53
+ throw Object.assign(new Error('Forbidden — workspaceId header required'), { status: 403 })
54
+ }
55
+ if (!ActionService.hasHandler(actionId)) {
56
+ throw Object.assign(new Error(`Action has no server handler: ${actionId}`), { status: 405 })
57
+ }
58
+
59
+ if (actionEntitlementIndex && loadWorkspaceForEntitlements) {
60
+ await assertActionEntitled({
61
+ actionId,
62
+ access: action.access,
63
+ workspaceId: payload?.workspaceId ?? req?.workspaceId,
64
+ entitlementIndex: actionEntitlementIndex,
65
+ loadWorkspace: loadWorkspaceForEntitlements,
66
+ })
67
+ }
68
+
69
+ const actionLog = createLogger(actionId)
70
+ return ActionService.invoke(actionId, {
71
+ payload: {
72
+ workspaceId: payload?.workspaceId ?? req?.workspaceId,
73
+ user: req?.user,
74
+ userId: req?.userId,
75
+ ...payload,
76
+ },
77
+ sdk: req?.sdk ?? null,
78
+ log: actionLog,
79
+ integrations: IntegrationService,
80
+ req,
81
+ })
82
+ }
83
+
84
+ mountOssyMcp({
85
+ app,
86
+ capabilities: resolvedCapabilities,
87
+ requireAuth,
88
+ invokeAction: invokePlatformAction,
89
+ customTools: [
90
+ {
91
+ ...UPLOAD_FILE_TOOL,
92
+ handler: (args, req) => uploadFileToolHandler(invokePlatformAction, args, req),
93
+ },
94
+ ],
95
+ })
96
+
97
+ log.info(`MCP mounted at /mcp (${resolvedCapabilities.tools?.length ?? 0} action tools, ${resolvedCapabilities.tasks?.length ?? 0} tasks in topology resource)`)
98
+ return resolvedCapabilities
99
+ }
100
+
101
+ export { buildCapabilities }
@@ -0,0 +1,62 @@
1
+ import { readFile, stat } from 'node:fs/promises'
2
+
3
+ /**
4
+ * Composite upload: create binary resource + PUT file bytes.
5
+ *
6
+ * @param {(actionId: string, payload: object, req?: object) => Promise<unknown>} invokeAction
7
+ * @param {import('express').Request} [req]
8
+ */
9
+ export async function uploadFileToolHandler (invokeAction, args, req) {
10
+ const filePath = args.filePath
11
+ if (!filePath || typeof filePath !== 'string') {
12
+ throw new Error('filePath is required')
13
+ }
14
+
15
+ const fileStat = await stat(filePath)
16
+ const body = await readFile(filePath)
17
+ const type = args.type || 'application/octet-stream'
18
+
19
+ const resource = await invokeAction('@ossy/resources/actions/create', {
20
+ workspaceId: args.workspaceId,
21
+ location: args.location,
22
+ name: args.name,
23
+ type,
24
+ size: fileStat.size,
25
+ }, req)
26
+
27
+ const uploadUrl = resource?.content?.uploadUrl
28
+ if (!uploadUrl) {
29
+ throw new Error('Storage is not configured or create did not return uploadUrl')
30
+ }
31
+
32
+ const response = await fetch(uploadUrl, {
33
+ method: 'PUT',
34
+ headers: { 'Content-Type': type },
35
+ body,
36
+ })
37
+
38
+ if (!response.ok) {
39
+ throw new Error(`Upload failed: HTTP ${response.status}`)
40
+ }
41
+
42
+ return invokeAction('@ossy/resources/actions/get', {
43
+ workspaceId: args.workspaceId,
44
+ resourceId: resource.id,
45
+ }, req)
46
+ }
47
+
48
+ export const UPLOAD_FILE_TOOL = {
49
+ name: 'ossy_storage_upload_file',
50
+ description: 'Create a binary file resource and upload bytes from a local file path.',
51
+ inputSchema: {
52
+ type: 'object',
53
+ required: ['location', 'name', 'type', 'filePath'],
54
+ properties: {
55
+ workspaceId: { type: 'string' },
56
+ location: { type: 'string', description: 'Parent folder path, e.g. /test/' },
57
+ name: { type: 'string', description: 'Filename with extension' },
58
+ type: { type: 'string', description: 'Mime type, e.g. image/png' },
59
+ filePath: { type: 'string', description: 'Absolute path to the file on disk' },
60
+ },
61
+ },
62
+ }
@@ -0,0 +1,92 @@
1
+ import { nanoid } from 'nanoid'
2
+ import { EventStore } from '@ossy/event-store'
3
+ import { createLogger, metrics } from '@ossy/observability'
4
+ import { PlatformSchema } from '../schema-ids.js'
5
+ import { moduleIdFromTaskId } from '../audit/audit-helpers.js'
6
+
7
+ const log = createLogger('platform/metering')
8
+
9
+ /**
10
+ * Append-only usage records for billing replay (Wave 3 — record only).
11
+ */
12
+ export const MeteringService = {
13
+
14
+ /**
15
+ * @param {{
16
+ * kind: 'action' | 'task',
17
+ * actionId?: string | null,
18
+ * taskId?: string | null,
19
+ * channel?: string | null,
20
+ * workspaceId?: string | null,
21
+ * actorId?: string | null,
22
+ * durationMs?: number | null,
23
+ * success?: boolean,
24
+ * actionInvocationId?: string | null,
25
+ * taskRunId?: string | null,
26
+ * trigger?: string | null,
27
+ * }} record
28
+ */
29
+ async record (record) {
30
+ const {
31
+ kind,
32
+ actionId = null,
33
+ taskId = null,
34
+ channel = null,
35
+ workspaceId = null,
36
+ actorId = null,
37
+ durationMs = null,
38
+ success = true,
39
+ actionInvocationId = null,
40
+ taskRunId = null,
41
+ trigger = null,
42
+ } = record
43
+
44
+ const moduleId = taskId
45
+ ? moduleIdFromTaskId(taskId)
46
+ : actionId
47
+ ? moduleIdFromTaskId(actionId)
48
+ : 'unknown'
49
+
50
+ const envelope = {
51
+ kind,
52
+ moduleId,
53
+ actionId,
54
+ taskId,
55
+ channel,
56
+ workspaceId,
57
+ actorId,
58
+ durationMs,
59
+ success,
60
+ actionInvocationId,
61
+ taskRunId,
62
+ trigger,
63
+ recordedAt: Date.now(),
64
+ }
65
+
66
+ try {
67
+ metrics.increment('meter.record', { kind, module: moduleId, success: String(success) })
68
+ if (durationMs != null) {
69
+ metrics.timing('meter.duration', durationMs, { kind, module: moduleId })
70
+ }
71
+ } catch {}
72
+
73
+ try {
74
+ await EventStore.AppendResourceEvent({
75
+ id: nanoid(),
76
+ type: PlatformSchema.meterEvent,
77
+ resourceId: nanoid(),
78
+ event: 'Recorded',
79
+ version: 1,
80
+ created: Date.now(),
81
+ createdBy: actorId ?? undefined,
82
+ payload: {
83
+ ...envelope,
84
+ belongsTo: workspaceId,
85
+ location: '/@ossy/metering/',
86
+ },
87
+ })
88
+ } catch (err) {
89
+ log.warn('[MeteringService] Failed to persist meter event', envelope, err)
90
+ }
91
+ },
92
+ }
@@ -10,7 +10,7 @@
10
10
  */
11
11
  export default {
12
12
  name: 'Platform config',
13
- id: '@ossy/platform/config',
13
+ id: '@ossy/platform/schema/config',
14
14
  icon: 'controller',
15
15
  fields: [
16
16
  {
@@ -1,4 +1,8 @@
1
1
  import { createLogger } from '@ossy/observability'
2
+ import {
3
+ mergeUserAppSettingsCookie,
4
+ readUserAppSettings,
5
+ } from './user-app-settings.js'
2
6
 
3
7
  const log = createLogger('platform')
4
8
 
@@ -23,19 +27,7 @@ export function ProxyInternal () {
23
27
  }
24
28
 
25
29
  const requestedSettings = req.body
26
- const expiresMaxAge = 2147483647
27
- const userSettings = JSON.parse(req.signedCookies?.['x-ossy-user-settings'] || '{}')
28
-
29
- const updatedSettings = {
30
- ...userSettings,
31
- ...requestedSettings,
32
- }
33
-
34
- res.cookie('x-ossy-user-settings', JSON.stringify(updatedSettings), {
35
- httpOnly: true,
36
- signed: true,
37
- expires: new Date(Date.now() + expiresMaxAge),
38
- })
30
+ mergeUserAppSettingsCookie(req, res, requestedSettings)
39
31
 
40
32
  res.status(201)
41
33
  res.json('')
@@ -44,7 +36,7 @@ export function ProxyInternal () {
44
36
 
45
37
  if (req.originalUrl.startsWith('/@ossy/users/me/app-settings') && req.method === 'GET') {
46
38
  log.info('[@ossy/platform][proxy] GET /@ossy/users/me/app-settings')
47
- const userSettings = JSON.parse(req.signedCookies?.['x-ossy-user-settings'] || '{}')
39
+ const userSettings = readUserAppSettings(req)
48
40
  res.status(200)
49
41
  res.json(userSettings)
50
42
  return
@@ -52,8 +44,13 @@ export function ProxyInternal () {
52
44
 
53
45
  log.info(`[@ossy/platform][proxy] ${req.method} ${req.originalUrl}`)
54
46
 
55
- const domain = process.env.OSSY_API_URL || 'https://api.ossy.se'
56
- const url = `${domain}${req.originalUrl?.replace('/@ossy', '/api/v0')}`
47
+ const domain = (process.env.OSSY_API_URL || 'https://api.ossy.se')
48
+ .replace(/\/api\/v0\/?$/, '')
49
+ .replace(/\/$/, '')
50
+ const pathAfterOssy = req.originalUrl.replace(/^\/@ossy/, '') || '/'
51
+ // POST /actions lives on the app server root — not under /api/v0.
52
+ const upstreamPath = pathAfterOssy === '/actions' ? '/actions' : `/api/v0${pathAfterOssy}`
53
+ const url = `${domain}${upstreamPath}`
57
54
  const forwardedHeaders = JSON.parse(JSON.stringify(req.headers))
58
55
  const workspaceId = normalizeWorkspaceIdHeader(req.get('workspaceId'))
59
56
 
@@ -0,0 +1,43 @@
1
+ import { PushInvalidation } from '@ossy/event-store'
2
+ import { createLogger } from '@ossy/observability'
3
+
4
+ const log = createLogger('@ossy/platform')
5
+
6
+ const HEARTBEAT_MS = 25_000
7
+
8
+ /**
9
+ * ADR 0008 §9 — workspace-scoped SSE invalidation bus.
10
+ *
11
+ * @param {import('express').Express} app
12
+ */
13
+ export function mountPushSse (app) {
14
+ app.get('/events', (req, res) => {
15
+ const workspaceId = req.workspaceId
16
+ if (!workspaceId) {
17
+ res.status(403).json({ error: 'Forbidden' })
18
+ return
19
+ }
20
+
21
+ res.setHeader('Content-Type', 'text/event-stream; charset=utf-8')
22
+ res.setHeader('Cache-Control', 'no-cache, no-transform')
23
+ res.setHeader('Connection', 'keep-alive')
24
+ res.flushHeaders?.()
25
+
26
+ const send = (message) => {
27
+ res.write(`data: ${JSON.stringify(message)}\n\n`)
28
+ }
29
+
30
+ send({ kind: 'connected', scope: { workspaceId } })
31
+
32
+ const unsubscribe = PushInvalidation.subscribe(workspaceId, send)
33
+ const heartbeat = setInterval(() => {
34
+ res.write(': heartbeat\n\n')
35
+ }, HEARTBEAT_MS)
36
+
37
+ req.on('close', () => {
38
+ clearInterval(heartbeat)
39
+ unsubscribe()
40
+ log.debug(`[push] SSE closed for workspace ${workspaceId}`)
41
+ })
42
+ })
43
+ }
@@ -1,2 +1,3 @@
1
- export { registerResourceTemplate, getSystemResourceTemplates } from './resource-template.registry.js'
2
- export { normalizeAndValidateDocumentContent, validateResourceTemplatesForImport, ALLOWED_FIELD_TYPES } from './resource-template.validation.js'
1
+ export { registerSchema, getSystemSchemas } from './schema.registry.js'
2
+ export { getPlatformSchema, initPlatformSchema, createSchemaEngine, schemaForWorkspace } from './schema.service.js'
3
+ export { validateSchemasForImport, ALLOWED_FIELD_TYPES, normalizeFieldType, resolveFieldDef } from './schema.validation.js'
@@ -0,0 +1,26 @@
1
+ import { createLogger } from '@ossy/observability'
2
+
3
+ const log = createLogger('platform')
4
+
5
+ /** @type {object[]} */
6
+ const systemSchemas = []
7
+
8
+ /**
9
+ * Register a single schema POJO (as inlined in `build/manifest.json`).
10
+ *
11
+ * @param {object} schema
12
+ */
13
+ export function registerSchema (schema) {
14
+ if (!schema || typeof schema !== 'object' || Array.isArray(schema)) return
15
+ if (typeof schema.id !== 'string' || schema.id.trim() === '') return
16
+ if (systemSchemas.find(s => s.id === schema.id)) return
17
+ systemSchemas.push(schema)
18
+ log.info(`[SchemaRegistry] Registered system schema: ${schema.id}`)
19
+ }
20
+
21
+ /**
22
+ * @returns {object[]}
23
+ */
24
+ export function getSystemSchemas () {
25
+ return systemSchemas
26
+ }
@@ -0,0 +1,54 @@
1
+ import { Schema } from '@ossy/schema'
2
+
3
+ /** @type {Schema | null} */
4
+ let platformSchema = null
5
+
6
+ /**
7
+ * Platform-wide schema engine from build manifest (no workspace overlays).
8
+ *
9
+ * @param {{ schemas?: object[], actions?: { id: string }[], tasks?: { id: string }[] }} manifest
10
+ */
11
+ export function initPlatformSchema (manifest) {
12
+ platformSchema = Schema.of(manifest, {
13
+ actionIds: (manifest.actions ?? []).map((a) => a.id),
14
+ taskIds: (manifest.tasks ?? []).map((t) => t.id),
15
+ strictRefs: false,
16
+ })
17
+ return platformSchema
18
+ }
19
+
20
+ /**
21
+ * @returns {Schema}
22
+ */
23
+ export function getPlatformSchema () {
24
+ if (!platformSchema) {
25
+ throw new Error('[@ossy/platform] Platform Schema not initialized — call initPlatformSchema after loadManifest')
26
+ }
27
+ return platformSchema
28
+ }
29
+
30
+ /**
31
+ * Build-time or per-request engine with optional strict ref checks.
32
+ *
33
+ * @param {{ schemas?: object[], actions?: { id: string }[], tasks?: { id: string }[] }} manifest
34
+ * @param {{ strictRefs?: boolean, actionIds?: string[], taskIds?: string[] }} [options]
35
+ */
36
+ export function createSchemaEngine (manifest, options = {}) {
37
+ return Schema.of(manifest, {
38
+ actionIds: options.actionIds ?? (manifest.actions ?? []).map((a) => a.id),
39
+ taskIds: options.taskIds ?? (manifest.tasks ?? []).map((t) => t.id),
40
+ strictRefs: options.strictRefs ?? false,
41
+ })
42
+ }
43
+
44
+ /**
45
+ * Platform schema engine with optional workspace schema overlays.
46
+ *
47
+ * @param {object[]} [workspaceSchemas]
48
+ */
49
+ export function schemaForWorkspace (workspaceSchemas = []) {
50
+ const platform = getPlatformSchema()
51
+ const extra = (workspaceSchemas ?? []).filter((s) => s?.id && !platform.has(s.id))
52
+ if (!extra.length) return platform
53
+ return platform.withWorkspaceSchemas(extra)
54
+ }
@@ -0,0 +1,90 @@
1
+ import { Schema } from '@ossy/schema'
2
+
3
+ /** Field input types accepted in schema definitions (UI + API contract). */
4
+ export const ALLOWED_FIELD_TYPES = new Set([
5
+ 'text',
6
+ 'email',
7
+ 'textarea',
8
+ 'richtext',
9
+ 'number',
10
+ 'select',
11
+ 'multiselect',
12
+ 'file',
13
+ 'image',
14
+ 'boolean',
15
+ 'date',
16
+ 'timestamp',
17
+ 'date-range',
18
+ 'date-ranges',
19
+ 'id',
20
+ 'path',
21
+ ])
22
+
23
+ /**
24
+ * Normalize template field `type` to the canonical editor type.
25
+ * `image` is an alias for `file` with default `accept: 'image/*'` when accept is omitted.
26
+ *
27
+ * @param {string | undefined} type
28
+ * @returns {{ type: string, defaultAccept?: string }}
29
+ */
30
+ export function normalizeFieldType (type) {
31
+ const raw = typeof type === 'string' ? type.trim() : ''
32
+ if (raw === 'image') return { type: 'file', defaultAccept: 'image/*' }
33
+ return { type: raw }
34
+ }
35
+
36
+ /**
37
+ * @param {{ type?: string, accept?: string, min?: number, max?: number } & Record<string, unknown>} field
38
+ */
39
+ export function resolveFieldDef (field) {
40
+ const { type: canonical, defaultAccept } = normalizeFieldType(field?.type)
41
+ return {
42
+ ...field,
43
+ type: canonical,
44
+ accept: field?.accept ?? defaultAccept,
45
+ max: field?.max ?? 1,
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Validates a batch import of workspace schemas.
51
+ *
52
+ * @param {unknown} templates
53
+ * @param {Set<string>} reservedIds - System schema ids that must not be redefined
54
+ * @returns {{ ok: true } | { ok: false, code: string, message: string }}
55
+ */
56
+ export function validateSchemasForImport (templates, reservedIds) {
57
+ if (!Array.isArray(templates)) {
58
+ return { ok: false, code: 'INVALID_PAYLOAD', message: 'Body must be a JSON array of schemas' }
59
+ }
60
+
61
+ const engine = Schema.of({ schemas: templates })
62
+ const seenIds = new Set()
63
+
64
+ for (const template of templates) {
65
+ if (reservedIds?.has(template?.id)) {
66
+ return {
67
+ ok: false,
68
+ code: 'RESERVED_SCHEMA_ID',
69
+ message: `Template id "${template.id}" is reserved by a system template`,
70
+ }
71
+ }
72
+
73
+ const result = engine.validate(template)
74
+ if (!result.ok) {
75
+ const first = result.errors[0]
76
+ return { ok: false, code: first.code, message: first.message }
77
+ }
78
+
79
+ if (seenIds.has(template.id)) {
80
+ return {
81
+ ok: false,
82
+ code: 'DUPLICATE_SCHEMA_ID',
83
+ message: `Duplicate schema id "${template.id}" in import payload`,
84
+ }
85
+ }
86
+ seenIds.add(template.id)
87
+ }
88
+
89
+ return { ok: true }
90
+ }
package/src/runtime.js CHANGED
@@ -174,8 +174,11 @@ export async function startRuntime ({ port } = {}) {
174
174
  ...(req.userAppSettings || {}),
175
175
  theme: req.userAppSettings?.theme || cloneSerializable(config?.theme),
176
176
  themes: cloneSerializable(config?.themes),
177
- resourceTemplates: cloneSerializable(manifest.resourceTemplates),
177
+ schemas: cloneSerializable(manifest.schemas),
178
178
  manifestSummary: cloneSerializable(manifestSummary),
179
+ actionRoutes: cloneSerializable(manifest.actionRoutes || {}),
180
+ taskCatalog: cloneSerializable(manifest.taskCatalog || []),
181
+ taskGraphEdges: cloneSerializable(manifest.taskGraphEdges || []),
179
182
  url: requestUrl,
180
183
  isAuthenticated: !!req.isAuthenticated,
181
184
  language,
@@ -0,0 +1,9 @@
1
+ /** Platform envelope schemas (ADR 0008). */
2
+ export const PlatformSchema = Object.freeze({
3
+ config: '@ossy/platform/schema/config',
4
+ directory: '@ossy/platform/schema/directory',
5
+ file: '@ossy/platform/schema/file',
6
+ taskRun: '@ossy/platform/schema/task-run',
7
+ actionInvocation: '@ossy/platform/schema/action-invocation',
8
+ meterEvent: '@ossy/platform/schema/meter-event',
9
+ })