@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.
- package/README.md +21 -17
- package/package.json +20 -11
- package/src/Definition.js +1 -0
- package/src/PlatformShell.jsx +10 -10
- package/src/actions/action.service.js +79 -6
- package/src/audit/action-invocation-service.js +144 -0
- package/src/audit/action-invocation.aggregate.js +59 -0
- package/src/audit/audit-helpers.js +61 -0
- package/src/audit/detect-channel.js +19 -0
- package/src/audit/list-task-runs.action.js +5 -0
- package/src/audit/list-task-runs.task.js +17 -0
- package/src/audit/task-run-list.aggregate.js +78 -0
- package/src/audit/task-run-service.js +193 -0
- package/src/audit/task-run.aggregate.js +60 -0
- package/src/auth/action-scopes.js +3 -0
- package/src/capability-schemas/action-capability.schema.js +1 -0
- package/src/capability-schemas/action-meta.schema.js +1 -0
- package/src/capability-schemas/component-capability.schema.js +1 -0
- package/src/capability-schemas/page-capability.schema.js +1 -0
- package/src/capability-schemas/page-meta.schema.js +1 -0
- package/src/capability-schemas/task-capability.schema.js +1 -0
- package/src/capability-schemas/task-meta.schema.js +1 -0
- package/src/capability-schemas/task-output.schema.js +1 -0
- package/src/capability-schemas/task-trigger-authoring.schema.js +1 -0
- package/src/capability-schemas/task-trigger.schema.js +1 -0
- package/src/directory.schema.js +9 -0
- package/src/entitlements/action-entitlement.js +69 -0
- package/src/file.schema.js +13 -0
- package/src/index.js +13 -3
- package/src/mcp/create-ossy-mcp-server.js +97 -0
- package/src/mcp/json-schema-to-zod.js +64 -0
- package/src/mcp/mount-ossy-mcp.js +72 -0
- package/src/mcp/mount-platform-mcp.js +101 -0
- package/src/mcp/upload-file-tool.js +62 -0
- package/src/metering/metering-service.js +92 -0
- package/src/{platform-config.resource.js → platform-config.schema.js} +1 -1
- package/src/proxy-internal.js +13 -16
- package/src/push/mount-push-sse.js +43 -0
- package/src/resources/index.js +3 -2
- package/src/resources/schema.registry.js +26 -0
- package/src/resources/schema.service.js +54 -0
- package/src/resources/schema.validation.js +90 -0
- package/src/runtime.js +4 -1
- package/src/schema-ids.js +9 -0
- package/src/server.js +121 -20
- package/src/storage/filesystem-storage.client.js +109 -0
- package/src/storage/local-storage.client.js +2 -65
- package/src/storage/resource-read-url.js +36 -0
- package/src/storage/resource-read-url.spec.js +22 -0
- package/src/storage/s3-storage.client.js +102 -0
- package/src/storage/s3.client.js +27 -23
- package/src/storage/storage-keys.js +37 -0
- package/src/storage/storage-keys.spec.js +16 -0
- package/src/storage/storage.client.js +52 -8
- package/src/storage/storage.integration.js +40 -0
- package/src/tasks/change-stream.js +9 -2
- package/src/tasks/task-service.js +211 -34
- package/src/tasks/task-service.spec.js +187 -0
- package/src/test/e2e.util.js +18 -39
- package/src/test/flow-runner.js +476 -0
- package/src/test/test.util.js +30 -29
- package/src/user-app-settings.js +44 -0
- package/src/users.middleware.js +14 -2
- package/src/resources/resource-template.registry.js +0 -29
- 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
|
+
}
|
package/src/proxy-internal.js
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
+
}
|
package/src/resources/index.js
CHANGED
|
@@ -1,2 +1,3 @@
|
|
|
1
|
-
export {
|
|
2
|
-
export {
|
|
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
|
-
|
|
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
|
+
})
|