@ossy/platform 1.39.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.
- package/README.md +21 -17
- package/package.json +20 -11
- package/src/Definition.js +2 -1
- package/src/PlatformShell.jsx +10 -10
- package/src/actions/action.service.js +85 -7
- package/src/audit/action-invocation-service.js +143 -0
- package/src/audit/action-invocation.aggregate.js +57 -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 +76 -0
- package/src/audit/task-run-service.js +208 -0
- package/src/audit/task-run.aggregate.js +58 -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 +7 -0
- package/src/entitlements/action-entitlement.js +69 -0
- package/src/file.schema.js +11 -0
- package/src/index.js +12 -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 +91 -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 +91 -0
- package/src/request-diagnostics.js +144 -0
- package/src/request-diagnostics.spec.js +40 -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 +20 -6
- package/src/server.js +281 -62
- 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 +78 -11
- 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 +61 -17
- 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,91 @@
|
|
|
1
|
+
import { nanoid } from 'nanoid'
|
|
2
|
+
import { EventStore } from '@ossy/event-store'
|
|
3
|
+
import { createLogger, metrics } from '@ossy/observability'
|
|
4
|
+
import { moduleIdFromTaskId } from '../audit/audit-helpers.js'
|
|
5
|
+
|
|
6
|
+
const log = createLogger('platform/metering')
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Append-only usage records for billing replay (Wave 3 — record only).
|
|
10
|
+
*/
|
|
11
|
+
export const MeteringService = {
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @param {{
|
|
15
|
+
* kind: 'action' | 'task',
|
|
16
|
+
* actionId?: string | null,
|
|
17
|
+
* taskId?: string | null,
|
|
18
|
+
* channel?: string | null,
|
|
19
|
+
* workspaceId?: string | null,
|
|
20
|
+
* actorId?: string | null,
|
|
21
|
+
* durationMs?: number | null,
|
|
22
|
+
* success?: boolean,
|
|
23
|
+
* actionInvocationId?: string | null,
|
|
24
|
+
* taskRunId?: string | null,
|
|
25
|
+
* trigger?: string | null,
|
|
26
|
+
* }} record
|
|
27
|
+
*/
|
|
28
|
+
async record (record) {
|
|
29
|
+
const {
|
|
30
|
+
kind,
|
|
31
|
+
actionId = null,
|
|
32
|
+
taskId = null,
|
|
33
|
+
channel = null,
|
|
34
|
+
workspaceId = null,
|
|
35
|
+
actorId = null,
|
|
36
|
+
durationMs = null,
|
|
37
|
+
success = true,
|
|
38
|
+
actionInvocationId = null,
|
|
39
|
+
taskRunId = null,
|
|
40
|
+
trigger = null,
|
|
41
|
+
} = record
|
|
42
|
+
|
|
43
|
+
const moduleId = taskId
|
|
44
|
+
? moduleIdFromTaskId(taskId)
|
|
45
|
+
: actionId
|
|
46
|
+
? moduleIdFromTaskId(actionId)
|
|
47
|
+
: 'unknown'
|
|
48
|
+
|
|
49
|
+
const envelope = {
|
|
50
|
+
kind,
|
|
51
|
+
moduleId,
|
|
52
|
+
actionId,
|
|
53
|
+
taskId,
|
|
54
|
+
channel,
|
|
55
|
+
workspaceId,
|
|
56
|
+
actorId,
|
|
57
|
+
durationMs,
|
|
58
|
+
success,
|
|
59
|
+
actionInvocationId,
|
|
60
|
+
taskRunId,
|
|
61
|
+
trigger,
|
|
62
|
+
recordedAt: Date.now(),
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
metrics.increment('meter.record', { kind, module: moduleId, success: String(success) })
|
|
67
|
+
if (durationMs != null) {
|
|
68
|
+
metrics.timing('meter.duration', durationMs, { kind, module: moduleId })
|
|
69
|
+
}
|
|
70
|
+
} catch {}
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
await EventStore.AppendResourceEvent({
|
|
74
|
+
id: nanoid(),
|
|
75
|
+
type: '@ossy/platform/schema/meter-event',
|
|
76
|
+
resourceId: nanoid(),
|
|
77
|
+
event: 'Recorded',
|
|
78
|
+
version: 1,
|
|
79
|
+
created: Date.now(),
|
|
80
|
+
createdBy: actorId ?? undefined,
|
|
81
|
+
payload: {
|
|
82
|
+
...envelope,
|
|
83
|
+
belongsTo: workspaceId,
|
|
84
|
+
location: '/@ossy/metering/',
|
|
85
|
+
},
|
|
86
|
+
})
|
|
87
|
+
} catch (err) {
|
|
88
|
+
log.warn('[MeteringService] Failed to persist meter event', envelope, err)
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
}
|
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,91 @@
|
|
|
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
|
+
/** @type {Map<string, number>} */
|
|
9
|
+
const activeConnectionsByWorkspace = new Map()
|
|
10
|
+
|
|
11
|
+
/** @type {Set<{ res: import('express').Response, heartbeat: ReturnType<typeof setInterval>, unsubscribe: () => void, workspaceId: string }>} */
|
|
12
|
+
const activeConnections = new Set()
|
|
13
|
+
|
|
14
|
+
function trackConnection (workspaceId, delta) {
|
|
15
|
+
const next = Math.max(0, (activeConnectionsByWorkspace.get(workspaceId) ?? 0) + delta)
|
|
16
|
+
if (next === 0) {
|
|
17
|
+
activeConnectionsByWorkspace.delete(workspaceId)
|
|
18
|
+
} else {
|
|
19
|
+
activeConnectionsByWorkspace.set(workspaceId, next)
|
|
20
|
+
}
|
|
21
|
+
return next
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function releaseConnection (connection) {
|
|
25
|
+
if (!activeConnections.delete(connection)) return
|
|
26
|
+
|
|
27
|
+
clearInterval(connection.heartbeat)
|
|
28
|
+
connection.unsubscribe()
|
|
29
|
+
const remaining = trackConnection(connection.workspaceId, -1)
|
|
30
|
+
log.debug('[push] SSE closed', {
|
|
31
|
+
workspaceId: connection.workspaceId,
|
|
32
|
+
activeForWorkspace: remaining,
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Tear down all open SSE connections so HTTP server shutdown is not blocked.
|
|
38
|
+
*/
|
|
39
|
+
export function closePushSseConnections () {
|
|
40
|
+
for (const connection of [...activeConnections]) {
|
|
41
|
+
releaseConnection(connection)
|
|
42
|
+
if (!connection.res.writableEnded) {
|
|
43
|
+
connection.res.end()
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* ADR 0008 §9 — workspace-scoped SSE invalidation bus.
|
|
50
|
+
*
|
|
51
|
+
* @param {import('express').Express} app
|
|
52
|
+
*/
|
|
53
|
+
export function mountPushSse (app) {
|
|
54
|
+
app.get('/events', (req, res) => {
|
|
55
|
+
const workspaceId = req.workspaceId
|
|
56
|
+
if (!workspaceId) {
|
|
57
|
+
res.status(403).json({ error: 'Forbidden' })
|
|
58
|
+
return
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8')
|
|
62
|
+
res.setHeader('Cache-Control', 'no-cache, no-transform')
|
|
63
|
+
res.setHeader('Connection', 'keep-alive')
|
|
64
|
+
res.flushHeaders?.()
|
|
65
|
+
|
|
66
|
+
const send = (message) => {
|
|
67
|
+
res.write(`data: ${JSON.stringify(message)}\n\n`)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
send({ kind: 'connected', scope: { workspaceId } })
|
|
71
|
+
|
|
72
|
+
const unsubscribe = PushInvalidation.subscribe(workspaceId, send)
|
|
73
|
+
const activeForWorkspace = trackConnection(workspaceId, 1)
|
|
74
|
+
log.debug('[push] SSE opened', {
|
|
75
|
+
workspaceId,
|
|
76
|
+
activeForWorkspace,
|
|
77
|
+
userId: req.userId,
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
const heartbeat = setInterval(() => {
|
|
81
|
+
res.write(': heartbeat\n\n')
|
|
82
|
+
}, HEARTBEAT_MS)
|
|
83
|
+
|
|
84
|
+
const connection = { res, heartbeat, unsubscribe, workspaceId }
|
|
85
|
+
activeConnections.add(connection)
|
|
86
|
+
|
|
87
|
+
req.on('close', () => {
|
|
88
|
+
releaseConnection(connection)
|
|
89
|
+
})
|
|
90
|
+
})
|
|
91
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { createLogger } from '@ossy/observability'
|
|
2
|
+
|
|
3
|
+
const log = createLogger('@ossy/platform/request-diagnostics')
|
|
4
|
+
|
|
5
|
+
export class OperationTimeoutError extends Error {
|
|
6
|
+
constructor (label, timeoutMs) {
|
|
7
|
+
super(`Operation timed out after ${timeoutMs}ms: ${label}`)
|
|
8
|
+
this.name = 'OperationTimeoutError'
|
|
9
|
+
this.label = label
|
|
10
|
+
this.timeoutMs = timeoutMs
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function resolveTimeoutMs (envKey, fallback) {
|
|
15
|
+
const raw = process.env[envKey]
|
|
16
|
+
const parsed = Number.parseInt(String(raw ?? ''), 10)
|
|
17
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Reject when `promise` does not settle within `timeoutMs`.
|
|
22
|
+
* Pass `timeoutMs <= 0` to disable (returns `promise` unchanged).
|
|
23
|
+
*
|
|
24
|
+
* When `abortController` is supplied, aborts its signal on timeout so callers
|
|
25
|
+
* can propagate cancellation into long-running work.
|
|
26
|
+
*
|
|
27
|
+
* @template T
|
|
28
|
+
* @param {Promise<T>} promise
|
|
29
|
+
* @param {number} timeoutMs
|
|
30
|
+
* @param {string} label
|
|
31
|
+
* @param {{ abortController?: AbortController }} [options]
|
|
32
|
+
* @returns {Promise<T>}
|
|
33
|
+
*/
|
|
34
|
+
export function withTimeout (promise, timeoutMs, label, options = {}) {
|
|
35
|
+
if (!timeoutMs || timeoutMs <= 0) return promise
|
|
36
|
+
|
|
37
|
+
const { abortController } = options
|
|
38
|
+
let timer
|
|
39
|
+
|
|
40
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
41
|
+
timer = setTimeout(() => {
|
|
42
|
+
const err = new OperationTimeoutError(label, timeoutMs)
|
|
43
|
+
abortController?.abort(err)
|
|
44
|
+
reject(err)
|
|
45
|
+
}, timeoutMs)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
const abortPromise = abortController
|
|
49
|
+
? new Promise((_, reject) => {
|
|
50
|
+
const { signal } = abortController
|
|
51
|
+
if (signal.aborted) {
|
|
52
|
+
reject(signal.reason ?? new OperationTimeoutError(label, timeoutMs))
|
|
53
|
+
return
|
|
54
|
+
}
|
|
55
|
+
signal.addEventListener('abort', () => {
|
|
56
|
+
reject(signal.reason ?? new OperationTimeoutError(label, timeoutMs))
|
|
57
|
+
}, { once: true })
|
|
58
|
+
})
|
|
59
|
+
: null
|
|
60
|
+
|
|
61
|
+
const races = abortPromise ? [promise, timeoutPromise, abortPromise] : [promise, timeoutPromise]
|
|
62
|
+
|
|
63
|
+
return Promise.race(races).finally(() => {
|
|
64
|
+
clearTimeout(timer)
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Reject when a signal aborts (used to race cooperative task handlers).
|
|
70
|
+
*
|
|
71
|
+
* @param {AbortSignal} signal
|
|
72
|
+
* @returns {Promise<never>}
|
|
73
|
+
*/
|
|
74
|
+
export function rejectOnAbort (signal) {
|
|
75
|
+
return new Promise((_, reject) => {
|
|
76
|
+
if (signal.aborted) {
|
|
77
|
+
reject(signal.reason ?? new Error('Operation aborted'))
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
signal.addEventListener('abort', () => {
|
|
81
|
+
reject(signal.reason ?? new Error('Operation aborted'))
|
|
82
|
+
}, { once: true })
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Log when an operation exceeds the slow threshold.
|
|
88
|
+
*
|
|
89
|
+
* @param {string} label
|
|
90
|
+
* @param {{ log?: import('@ossy/observability').Logger, warnThresholdMs?: number }} [options]
|
|
91
|
+
*/
|
|
92
|
+
export function createTimedOperation (label, options = {}) {
|
|
93
|
+
const logger = options.log ?? log
|
|
94
|
+
const warnThresholdMs = options.warnThresholdMs
|
|
95
|
+
?? resolveTimeoutMs('OSSY_REQUEST_SLOW_MS', 5000)
|
|
96
|
+
const start = performance.now()
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
finish (detail) {
|
|
100
|
+
const elapsedMs = Math.round(performance.now() - start)
|
|
101
|
+
const payload = { label, elapsedMs, ...(detail ?? {}) }
|
|
102
|
+
if (elapsedMs >= warnThresholdMs) {
|
|
103
|
+
logger.warn('[slow-operation]', payload)
|
|
104
|
+
} else {
|
|
105
|
+
logger.debug('[timing]', payload)
|
|
106
|
+
}
|
|
107
|
+
return elapsedMs
|
|
108
|
+
},
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Express middleware — logs requests that exceed OSSY_REQUEST_SLOW_MS (default 5s).
|
|
114
|
+
*
|
|
115
|
+
* @param {import('@ossy/observability').Logger} [logger]
|
|
116
|
+
*/
|
|
117
|
+
export function createSlowRequestLogger (logger = log) {
|
|
118
|
+
const slowThresholdMs = resolveTimeoutMs('OSSY_REQUEST_SLOW_MS', 5000)
|
|
119
|
+
|
|
120
|
+
return (req, res, next) => {
|
|
121
|
+
const start = performance.now()
|
|
122
|
+
const requestUrl = req.originalUrl || req.url || '/'
|
|
123
|
+
|
|
124
|
+
const onFinished = () => {
|
|
125
|
+
res.removeListener('finish', onFinished)
|
|
126
|
+
res.removeListener('close', onFinished)
|
|
127
|
+
const elapsedMs = Math.round(performance.now() - start)
|
|
128
|
+
if (elapsedMs < slowThresholdMs) return
|
|
129
|
+
|
|
130
|
+
logger.warn('[slow-request]', {
|
|
131
|
+
method: req.method,
|
|
132
|
+
url: requestUrl,
|
|
133
|
+
elapsedMs,
|
|
134
|
+
statusCode: res.statusCode,
|
|
135
|
+
userId: req.userId,
|
|
136
|
+
workspaceId: req.workspaceId,
|
|
137
|
+
})
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
res.on('finish', onFinished)
|
|
141
|
+
res.on('close', onFinished)
|
|
142
|
+
next()
|
|
143
|
+
}
|
|
144
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, expect, it } from '@jest/globals'
|
|
2
|
+
import { OperationTimeoutError, rejectOnAbort, withTimeout } from './request-diagnostics.js'
|
|
3
|
+
|
|
4
|
+
describe('request-diagnostics', () => {
|
|
5
|
+
it('withTimeout resolves when promise settles in time', async () => {
|
|
6
|
+
const result = await withTimeout(Promise.resolve('ok'), 100, 'fast')
|
|
7
|
+
expect(result).toBe('ok')
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
it('withTimeout rejects with OperationTimeoutError when promise is slow', async () => {
|
|
11
|
+
await expect(withTimeout(new Promise(() => {}), 20, 'slow-op')).rejects.toMatchObject({
|
|
12
|
+
label: 'slow-op',
|
|
13
|
+
timeoutMs: 20,
|
|
14
|
+
})
|
|
15
|
+
await expect(withTimeout(new Promise(() => {}), 20, 'slow-op')).rejects.toBeInstanceOf(OperationTimeoutError)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('withTimeout passes through when timeout is disabled', async () => {
|
|
19
|
+
const result = await withTimeout(Promise.resolve(42), 0, 'disabled')
|
|
20
|
+
expect(result).toBe(42)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('withTimeout aborts the supplied controller on timeout', async () => {
|
|
24
|
+
const abortController = new AbortController()
|
|
25
|
+
let aborted = false
|
|
26
|
+
abortController.signal.addEventListener('abort', () => { aborted = true })
|
|
27
|
+
|
|
28
|
+
await expect(
|
|
29
|
+
withTimeout(new Promise(() => {}), 20, 'abortable', { abortController }),
|
|
30
|
+
).rejects.toBeInstanceOf(OperationTimeoutError)
|
|
31
|
+
expect(aborted).toBe(true)
|
|
32
|
+
expect(abortController.signal.aborted).toBe(true)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('rejectOnAbort rejects when signal is already aborted', async () => {
|
|
36
|
+
const abortController = new AbortController()
|
|
37
|
+
abortController.abort(new Error('already gone'))
|
|
38
|
+
await expect(rejectOnAbort(abortController.signal)).rejects.toThrow(/already gone/)
|
|
39
|
+
})
|
|
40
|
+
})
|
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'
|