@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.
Files changed (66) hide show
  1. package/README.md +21 -17
  2. package/package.json +20 -11
  3. package/src/Definition.js +2 -1
  4. package/src/PlatformShell.jsx +10 -10
  5. package/src/actions/action.service.js +85 -7
  6. package/src/audit/action-invocation-service.js +143 -0
  7. package/src/audit/action-invocation.aggregate.js +57 -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 +76 -0
  13. package/src/audit/task-run-service.js +208 -0
  14. package/src/audit/task-run.aggregate.js +58 -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 +7 -0
  27. package/src/entitlements/action-entitlement.js +69 -0
  28. package/src/file.schema.js +11 -0
  29. package/src/index.js +12 -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 +91 -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 +91 -0
  39. package/src/request-diagnostics.js +144 -0
  40. package/src/request-diagnostics.spec.js +40 -0
  41. package/src/resources/index.js +3 -2
  42. package/src/resources/schema.registry.js +26 -0
  43. package/src/resources/schema.service.js +54 -0
  44. package/src/resources/schema.validation.js +90 -0
  45. package/src/runtime.js +20 -6
  46. package/src/server.js +281 -62
  47. package/src/storage/filesystem-storage.client.js +109 -0
  48. package/src/storage/local-storage.client.js +2 -65
  49. package/src/storage/resource-read-url.js +36 -0
  50. package/src/storage/resource-read-url.spec.js +22 -0
  51. package/src/storage/s3-storage.client.js +102 -0
  52. package/src/storage/s3.client.js +27 -23
  53. package/src/storage/storage-keys.js +37 -0
  54. package/src/storage/storage-keys.spec.js +16 -0
  55. package/src/storage/storage.client.js +52 -8
  56. package/src/storage/storage.integration.js +40 -0
  57. package/src/tasks/change-stream.js +78 -11
  58. package/src/tasks/task-service.js +211 -34
  59. package/src/tasks/task-service.spec.js +187 -0
  60. package/src/test/e2e.util.js +18 -39
  61. package/src/test/flow-runner.js +476 -0
  62. package/src/test/test.util.js +30 -29
  63. package/src/user-app-settings.js +44 -0
  64. package/src/users.middleware.js +61 -17
  65. package/src/resources/resource-template.registry.js +0 -29
  66. 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
+ }
@@ -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,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
+ })
@@ -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'