@ossy/platform 3.3.0 → 3.5.0
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/Dockerfile +12 -0
- package/README.md +3 -1
- package/docker-healthcheck.js +32 -0
- package/package.json +13 -12
- package/src/file.schema.js +4 -0
- package/src/health.js +21 -2
- package/src/health.spec.js +55 -2
- package/src/index.js +1 -0
- package/src/mcp/create-ossy-mcp-server.js +10 -6
- package/src/mcp/json-schema-to-zod.js +17 -1
- package/src/mcp/json-schema-to-zod.spec.js +103 -0
- package/src/mcp/mount-platform-mcp.js +2 -6
- package/src/mcp/normalize-mcp-action-args.js +30 -0
- package/src/mcp/upload-file-tool.js +13 -5
- package/src/runtime.js +2 -1
- package/src/server.js +36 -3
- package/src/storage/local-storage-url.js +16 -0
- package/src/storage/local-storage-url.spec.js +17 -0
- package/src/tasks/in-process-sdk.js +107 -0
- package/src/tasks/in-process-sdk.spec.js +89 -0
- package/src/tasks/task-service.js +4 -1
- package/src/test/flow-runner.js +38 -5
- package/src/test/playwright.config.js +8 -0
- package/src/token.service.js +7 -2
- package/src/token.service.spec.js +49 -0
- package/src/users.middleware.js +15 -5
- package/src/users.middleware.spec.js +123 -0
package/Dockerfile
CHANGED
|
@@ -13,11 +13,23 @@ COPY package.json ./
|
|
|
13
13
|
RUN npm install --omit=dev --no-audit --no-fund --loglevel=error
|
|
14
14
|
|
|
15
15
|
COPY src ./src
|
|
16
|
+
COPY docker-healthcheck.js ./docker-healthcheck.js
|
|
17
|
+
|
|
18
|
+
# ECS/ALB map container port 3000. `startRuntime` honors PORT (see runtime.js).
|
|
19
|
+
ENV PORT=3000
|
|
20
|
+
# Default label for `/health`; ECS task defs override with the service key.
|
|
21
|
+
ENV OSSY_SERVICE_NAME=runtime
|
|
16
22
|
|
|
17
23
|
EXPOSE 3000
|
|
18
24
|
|
|
25
|
+
# Image-local Docker HEALTHCHECK (PORT + /health + 4s abort). ECS container
|
|
26
|
+
# checks use the same probe shape via inline `node -e` fetch (ossy#589).
|
|
27
|
+
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
|
|
28
|
+
CMD ["node", "docker-healthcheck.js"]
|
|
29
|
+
|
|
19
30
|
# Required env vars at runtime:
|
|
20
31
|
# OSSY_API_KEY — Ossy API JWT for CMS reads
|
|
21
32
|
# OSSY_API_URL — (optional) override API base, default https://api.ossy.se/api/v0
|
|
22
33
|
# PORT — (optional) override listen port, default 3000
|
|
34
|
+
# OSSY_SERVICE_NAME — (optional) `/health` service label, default runtime
|
|
23
35
|
CMD ["node", "src/runtime.js"]
|
package/README.md
CHANGED
|
@@ -60,7 +60,7 @@ Optional environment variables used by the platform itself:
|
|
|
60
60
|
| Variable | Description |
|
|
61
61
|
|---|---|
|
|
62
62
|
| `DB_URL` | MongoDB connection string. Required for tasks and aggregates. |
|
|
63
|
-
| `API_URL` + `OSSY_API_KEY` | SDK
|
|
63
|
+
| `API_URL` + `OSSY_API_KEY` | Optional HTTP bot SDK for tasks. When unset, tasks get an in-process SDK that calls `ActionService` / storage in the same process (local app-test and same-server changestream). |
|
|
64
64
|
| `PORT` | HTTP port. |
|
|
65
65
|
|
|
66
66
|
## Health check
|
|
@@ -73,6 +73,8 @@ Both `startServer` (website / app images) and `startRuntime` (CMS multi-tenant i
|
|
|
73
73
|
|
|
74
74
|
The route is mounted before auth and before CMS site loading, so ALB target groups and ECS container health checks can use path `/health` without cookies, API tokens, or a resolvable hostname.
|
|
75
75
|
|
|
76
|
+
The runtime image `Dockerfile` sets `PORT` / `OSSY_SERVICE_NAME` and a Docker `HEALTHCHECK` via WORKDIR-root `docker-healthcheck.js` (same PORT + `/health` + 4s abort shape as ECS inline probes from `@ossy/deployment-tools`).
|
|
77
|
+
|
|
76
78
|
## Exported API
|
|
77
79
|
|
|
78
80
|
```js
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Docker HEALTHCHECK entrypoint for the platform runtime image (WORKDIR root).
|
|
3
|
+
*
|
|
4
|
+
* ECS Fargate container checks use an inline Node `fetch` one-liner from
|
|
5
|
+
* `@ossy/deployment-tools` `ecsContainerHealthCheckCommand` (ossy#589) — they
|
|
6
|
+
* do not require this file. Keep the same PORT + `/health` +
|
|
7
|
+
* `HEALTHCHECK_FETCH_TIMEOUT_MS` probe shape so local `docker build` /
|
|
8
|
+
* `docker run` health matches ECS/ALB liveness.
|
|
9
|
+
*
|
|
10
|
+
* No curl/wget — `node:*-slim` only needs Node `fetch`.
|
|
11
|
+
*/
|
|
12
|
+
const DEFAULT_PORT = 3000
|
|
13
|
+
const HEALTH_PATH = '/health'
|
|
14
|
+
/** Match `src/health.js` / deployment-tools `HEALTHCHECK_FETCH_TIMEOUT_MS`. */
|
|
15
|
+
const HEALTHCHECK_FETCH_TIMEOUT_MS = 4000
|
|
16
|
+
|
|
17
|
+
function resolveProbePort () {
|
|
18
|
+
const parsed = Number.parseInt(String(process.env.PORT ?? ''), 10)
|
|
19
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_PORT
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const port = resolveProbePort()
|
|
23
|
+
const url = `http://127.0.0.1:${port}${HEALTH_PATH}`
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
const response = await fetch(url, {
|
|
27
|
+
signal: AbortSignal.timeout(HEALTHCHECK_FETCH_TIMEOUT_MS),
|
|
28
|
+
})
|
|
29
|
+
process.exit(response.ok ? 0 : 1)
|
|
30
|
+
} catch {
|
|
31
|
+
process.exit(1)
|
|
32
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ossy/platform",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.5.0",
|
|
4
4
|
"description": "Ossy application server runtime",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
},
|
|
33
33
|
"scripts": {
|
|
34
34
|
"start": "PORT=3003 node -e \"import('./src/server.js').then(m => m.startServer())\"",
|
|
35
|
-
"test": "NODE_OPTIONS=--experimental-vm-modules jest --verbose"
|
|
35
|
+
"test": "NODE_OPTIONS=--experimental-vm-modules jest --verbose && node --test docker-healthcheck.test.js"
|
|
36
36
|
},
|
|
37
37
|
"keywords": [],
|
|
38
38
|
"author": "Ossy <yourfriends@ossy.se> (https://ossy.se)",
|
|
@@ -45,16 +45,16 @@
|
|
|
45
45
|
"@aws-sdk/util-format-url": "^3.972.17",
|
|
46
46
|
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
47
47
|
"@ossy/config": "^3.0.9",
|
|
48
|
-
"@ossy/event-store": "^3.
|
|
49
|
-
"@ossy/locale": "^3.0
|
|
50
|
-
"@ossy/manifest": "^3.0
|
|
48
|
+
"@ossy/event-store": "^3.4.0",
|
|
49
|
+
"@ossy/locale": "^3.4.0",
|
|
50
|
+
"@ossy/manifest": "^3.5.0",
|
|
51
51
|
"@ossy/observability": "^3.0.9",
|
|
52
52
|
"@ossy/policies": "^3.0.9",
|
|
53
|
-
"@ossy/schema": "^3.0
|
|
54
|
-
"@ossy/sdk": "^3.
|
|
55
|
-
"@ossy/tokens": "^3.0
|
|
56
|
-
"@ossy/users": "^3.
|
|
57
|
-
"@ossy/workspaces": "^3.
|
|
53
|
+
"@ossy/schema": "^3.5.0",
|
|
54
|
+
"@ossy/sdk": "^3.5.0",
|
|
55
|
+
"@ossy/tokens": "^3.5.0",
|
|
56
|
+
"@ossy/users": "^3.5.0",
|
|
57
|
+
"@ossy/workspaces": "^3.5.0",
|
|
58
58
|
"cookie-parser": "^1.4.7",
|
|
59
59
|
"dotenv": ">=16.0.0 <18.0.0",
|
|
60
60
|
"express": ">=5.0.0 <6.0.0",
|
|
@@ -72,7 +72,8 @@
|
|
|
72
72
|
},
|
|
73
73
|
"files": [
|
|
74
74
|
"src",
|
|
75
|
-
"Dockerfile"
|
|
75
|
+
"Dockerfile",
|
|
76
|
+
"docker-healthcheck.js"
|
|
76
77
|
],
|
|
77
|
-
"gitHead": "
|
|
78
|
+
"gitHead": "23167600f991596f122765a53f2b9df08314e115"
|
|
78
79
|
}
|
package/src/file.schema.js
CHANGED
|
@@ -7,5 +7,9 @@ export default {
|
|
|
7
7
|
{ name: 'Key', type: 'text' },
|
|
8
8
|
{ name: 'ContentLength', type: 'number' },
|
|
9
9
|
{ name: 'ContentType', type: 'text' },
|
|
10
|
+
// Populated async by @ossy/media-tasks/tasks/resize-common-web
|
|
11
|
+
{ name: 'blurhash', type: 'text' },
|
|
12
|
+
{ name: 'width', type: 'number' },
|
|
13
|
+
{ name: 'height', type: 'number' },
|
|
10
14
|
],
|
|
11
15
|
}
|
package/src/health.js
CHANGED
|
@@ -6,16 +6,35 @@
|
|
|
6
6
|
*/
|
|
7
7
|
export const HEALTH_PATH = '/health'
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* Abort hung Node `fetch` probes under the usual 5s Docker/ECS check timeout.
|
|
11
|
+
* Mirrored by `@ossy/deployment-tools` `HEALTHCHECK_FETCH_TIMEOUT_MS`.
|
|
12
|
+
*/
|
|
13
|
+
export const HEALTHCHECK_FETCH_TIMEOUT_MS = 4000
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param {string | undefined} explicit
|
|
17
|
+
* @returns {string}
|
|
18
|
+
*/
|
|
19
|
+
export function resolveHealthServiceName (explicit) {
|
|
20
|
+
if (typeof explicit === 'string' && explicit.trim()) return explicit.trim()
|
|
21
|
+
const fromEnv = typeof process.env.OSSY_SERVICE_NAME === 'string'
|
|
22
|
+
? process.env.OSSY_SERVICE_NAME.trim()
|
|
23
|
+
: ''
|
|
24
|
+
return fromEnv || 'ossy'
|
|
25
|
+
}
|
|
26
|
+
|
|
9
27
|
/**
|
|
10
28
|
* @param {import('express').Express} app
|
|
11
29
|
* @param {{ service?: string }} [options]
|
|
12
30
|
*/
|
|
13
|
-
export function mountHealthEndpoint (app, { service
|
|
31
|
+
export function mountHealthEndpoint (app, { service } = {}) {
|
|
32
|
+
const resolvedService = resolveHealthServiceName(service)
|
|
14
33
|
const handler = (_req, res) => {
|
|
15
34
|
res.status(200).json({
|
|
16
35
|
ok: true,
|
|
17
36
|
status: 'ok',
|
|
18
|
-
service,
|
|
37
|
+
service: resolvedService,
|
|
19
38
|
})
|
|
20
39
|
}
|
|
21
40
|
|
package/src/health.spec.js
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
|
-
import { describe, expect, it, jest } from '@jest/globals'
|
|
1
|
+
import { afterEach, describe, expect, it, jest } from '@jest/globals'
|
|
2
2
|
import express from 'express'
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
HEALTH_PATH,
|
|
5
|
+
HEALTHCHECK_FETCH_TIMEOUT_MS,
|
|
6
|
+
mountHealthEndpoint,
|
|
7
|
+
resolveHealthServiceName,
|
|
8
|
+
} from './health.js'
|
|
4
9
|
|
|
5
10
|
function createMockRes () {
|
|
6
11
|
const res = {
|
|
@@ -18,7 +23,38 @@ function createMockRes () {
|
|
|
18
23
|
return res
|
|
19
24
|
}
|
|
20
25
|
|
|
26
|
+
describe('resolveHealthServiceName', () => {
|
|
27
|
+
const original = process.env.OSSY_SERVICE_NAME
|
|
28
|
+
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
if (original === undefined) delete process.env.OSSY_SERVICE_NAME
|
|
31
|
+
else process.env.OSSY_SERVICE_NAME = original
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('prefers an explicit non-empty service', () => {
|
|
35
|
+
process.env.OSSY_SERVICE_NAME = 'from-env'
|
|
36
|
+
expect(resolveHealthServiceName('platform-runtime')).toBe('platform-runtime')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('uses OSSY_SERVICE_NAME when explicit is omitted', () => {
|
|
40
|
+
process.env.OSSY_SERVICE_NAME = 'website-ossy'
|
|
41
|
+
expect(resolveHealthServiceName()).toBe('website-ossy')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('defaults to ossy', () => {
|
|
45
|
+
delete process.env.OSSY_SERVICE_NAME
|
|
46
|
+
expect(resolveHealthServiceName()).toBe('ossy')
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
|
|
21
50
|
describe('mountHealthEndpoint', () => {
|
|
51
|
+
const original = process.env.OSSY_SERVICE_NAME
|
|
52
|
+
|
|
53
|
+
afterEach(() => {
|
|
54
|
+
if (original === undefined) delete process.env.OSSY_SERVICE_NAME
|
|
55
|
+
else process.env.OSSY_SERVICE_NAME = original
|
|
56
|
+
})
|
|
57
|
+
|
|
22
58
|
it('registers GET /health that returns 200 without auth', () => {
|
|
23
59
|
const app = express()
|
|
24
60
|
const get = jest.spyOn(app, 'get')
|
|
@@ -42,6 +78,7 @@ describe('mountHealthEndpoint', () => {
|
|
|
42
78
|
})
|
|
43
79
|
|
|
44
80
|
it('defaults service name to ossy', () => {
|
|
81
|
+
delete process.env.OSSY_SERVICE_NAME
|
|
45
82
|
const app = express()
|
|
46
83
|
const get = jest.spyOn(app, 'get')
|
|
47
84
|
mountHealthEndpoint(app)
|
|
@@ -50,4 +87,20 @@ describe('mountHealthEndpoint', () => {
|
|
|
50
87
|
handler({}, res)
|
|
51
88
|
expect(res.body.service).toBe('ossy')
|
|
52
89
|
})
|
|
90
|
+
|
|
91
|
+
it('labels via OSSY_SERVICE_NAME when set', () => {
|
|
92
|
+
process.env.OSSY_SERVICE_NAME = 'website-ossy'
|
|
93
|
+
const app = express()
|
|
94
|
+
const get = jest.spyOn(app, 'get')
|
|
95
|
+
mountHealthEndpoint(app)
|
|
96
|
+
const handler = get.mock.calls[0][1]
|
|
97
|
+
const res = createMockRes()
|
|
98
|
+
handler({}, res)
|
|
99
|
+
expect(res.body.service).toBe('website-ossy')
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('exports HEALTHCHECK_FETCH_TIMEOUT_MS under the ECS check timeout', () => {
|
|
103
|
+
expect(HEALTHCHECK_FETCH_TIMEOUT_MS).toBe(4000)
|
|
104
|
+
expect(HEALTHCHECK_FETCH_TIMEOUT_MS).toBeLessThan(5000)
|
|
105
|
+
})
|
|
53
106
|
})
|
package/src/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { TaskService } from './tasks/task-service.js'
|
|
2
|
+
export { createInProcessSdk } from './tasks/in-process-sdk.js'
|
|
2
3
|
export { loadAndRegisterTasks } from './tasks/task-registry.js'
|
|
3
4
|
export { ChangeStream } from './tasks/change-stream.js'
|
|
4
5
|
export { registerSchema, getSystemSchemas } from './resources/schema.registry.js'
|
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
|
2
|
-
import {
|
|
2
|
+
import { jsonSchemaToZod } from './json-schema-to-zod.js'
|
|
3
3
|
import {
|
|
4
4
|
TASK_TOPOLOGY_RESOURCE_URI,
|
|
5
5
|
capabilitiesTaskTopologyResource,
|
|
6
6
|
} from '@ossy/manifest/build-capabilities'
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
|
+
* Pass a full Zod object (with passthrough / record semantics) — not a raw shape.
|
|
10
|
+
* The MCP SDK wraps raw shapes with `z.object(shape)`, which strips unknown keys and
|
|
11
|
+
* defeats JSON Schema `additionalProperties: true` (e.g. timesheets `year` / `monthIndex`).
|
|
12
|
+
*
|
|
9
13
|
* @param {{
|
|
10
14
|
* capabilities: { tools: Array<object>, tasks?: object[], graph?: object },
|
|
11
15
|
* invokeAction: (actionId: string, payload: object, req?: object) => Promise<unknown>,
|
|
@@ -44,12 +48,12 @@ export function createOssyMcpServer ({ capabilities, invokeAction, customTools =
|
|
|
44
48
|
}
|
|
45
49
|
|
|
46
50
|
for (const tool of capabilities.tools || []) {
|
|
47
|
-
const
|
|
51
|
+
const inputSchema = jsonSchemaToZod(tool.inputSchema || { type: 'object' })
|
|
48
52
|
server.registerTool(
|
|
49
53
|
tool.name,
|
|
50
54
|
{
|
|
51
55
|
description: tool.description || tool.title || tool.actionId,
|
|
52
|
-
inputSchema
|
|
56
|
+
inputSchema,
|
|
53
57
|
},
|
|
54
58
|
async (args, extra) => {
|
|
55
59
|
try {
|
|
@@ -68,14 +72,14 @@ export function createOssyMcpServer ({ capabilities, invokeAction, customTools =
|
|
|
68
72
|
}
|
|
69
73
|
|
|
70
74
|
for (const tool of customTools) {
|
|
71
|
-
const
|
|
72
|
-
?
|
|
75
|
+
const inputSchema = tool.inputSchema
|
|
76
|
+
? jsonSchemaToZod(tool.inputSchema)
|
|
73
77
|
: undefined
|
|
74
78
|
server.registerTool(
|
|
75
79
|
tool.name,
|
|
76
80
|
{
|
|
77
81
|
description: tool.description,
|
|
78
|
-
...(
|
|
82
|
+
...(inputSchema ? { inputSchema } : {}),
|
|
79
83
|
},
|
|
80
84
|
async (args, extra) => {
|
|
81
85
|
try {
|
|
@@ -4,6 +4,11 @@ import { z } from 'zod'
|
|
|
4
4
|
* Minimal JSON Schema → Zod conversion for MCP tool registration.
|
|
5
5
|
* Supports the subset produced by build-capabilities.
|
|
6
6
|
*
|
|
7
|
+
* JSON Schema default for `additionalProperties` is **true** (open objects).
|
|
8
|
+
* We only strip unknown keys when `additionalProperties` is explicitly `false`.
|
|
9
|
+
* That matters for envelopes like `import-schemas` (`items: { type: 'object' }`),
|
|
10
|
+
* which otherwise became `z.object({})` and dropped `id` / `fields`.
|
|
11
|
+
*
|
|
7
12
|
* @param {object} schema
|
|
8
13
|
* @returns {import('zod').ZodTypeAny}
|
|
9
14
|
*/
|
|
@@ -26,7 +31,14 @@ export function jsonSchemaToZod (schema) {
|
|
|
26
31
|
shape[key] = field
|
|
27
32
|
}
|
|
28
33
|
const objectSchema = z.object(shape)
|
|
29
|
-
|
|
34
|
+
if (schema.additionalProperties === false) {
|
|
35
|
+
return objectSchema
|
|
36
|
+
}
|
|
37
|
+
// Open object with no declared properties → accept any keys.
|
|
38
|
+
if (Object.keys(shape).length === 0) {
|
|
39
|
+
return z.record(z.unknown())
|
|
40
|
+
}
|
|
41
|
+
return objectSchema.passthrough()
|
|
30
42
|
}
|
|
31
43
|
|
|
32
44
|
if (schema.type === 'array') {
|
|
@@ -52,6 +64,10 @@ export function jsonSchemaToZod (schema) {
|
|
|
52
64
|
}
|
|
53
65
|
|
|
54
66
|
/**
|
|
67
|
+
* Raw shape for callers that need ZodRawShape. Prefer {@link jsonSchemaToZod} when
|
|
68
|
+
* registering MCP tools — the MCP SDK rebuilds `z.object(shape)` without passthrough,
|
|
69
|
+
* which strips open-object fields.
|
|
70
|
+
*
|
|
55
71
|
* @param {object} inputSchema JSON Schema object
|
|
56
72
|
* @returns {import('zod').ZodRawShape}
|
|
57
73
|
*/
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { describe, expect, it } from '@jest/globals'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
import { jsonSchemaToZod, jsonSchemaToZodShape } from './json-schema-to-zod.js'
|
|
4
|
+
import { normalizeMcpActionArgs } from './normalize-mcp-action-args.js'
|
|
5
|
+
|
|
6
|
+
describe('jsonSchemaToZod', () => {
|
|
7
|
+
it('keeps unknown keys on open objects (JSON Schema default)', () => {
|
|
8
|
+
const schema = jsonSchemaToZod({ type: 'object' })
|
|
9
|
+
const parsed = schema.parse({
|
|
10
|
+
id: '@ossy/demo/schema/author',
|
|
11
|
+
fields: [{ name: 'n', type: 'text' }],
|
|
12
|
+
})
|
|
13
|
+
expect(parsed).toEqual({
|
|
14
|
+
id: '@ossy/demo/schema/author',
|
|
15
|
+
fields: [{ name: 'n', type: 'text' }],
|
|
16
|
+
})
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('strips unknown keys only when additionalProperties is false', () => {
|
|
20
|
+
const schema = jsonSchemaToZod({
|
|
21
|
+
type: 'object',
|
|
22
|
+
properties: { name: { type: 'string' } },
|
|
23
|
+
required: ['name'],
|
|
24
|
+
additionalProperties: false,
|
|
25
|
+
})
|
|
26
|
+
expect(schema.parse({ name: 'Ada', extra: true })).toEqual({ name: 'Ada' })
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('preserves schema items inside import-schemas-style arrays', () => {
|
|
30
|
+
const envelope = jsonSchemaToZod({
|
|
31
|
+
type: 'object',
|
|
32
|
+
required: ['schemas'],
|
|
33
|
+
properties: {
|
|
34
|
+
schemas: {
|
|
35
|
+
type: 'array',
|
|
36
|
+
items: { type: 'object' },
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
})
|
|
40
|
+
const input = {
|
|
41
|
+
schemas: [
|
|
42
|
+
{
|
|
43
|
+
id: '@ossy/demo/schema/author',
|
|
44
|
+
name: 'Author',
|
|
45
|
+
fields: [{ name: 'name', type: 'text', required: true }],
|
|
46
|
+
},
|
|
47
|
+
],
|
|
48
|
+
}
|
|
49
|
+
expect(envelope.parse(input)).toEqual(input)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('keeps timesheet generate fields when using the full Zod schema (MCP registration)', () => {
|
|
53
|
+
const capabilitySchema = {
|
|
54
|
+
type: 'object',
|
|
55
|
+
properties: {
|
|
56
|
+
workspaceId: { type: 'string' },
|
|
57
|
+
payload: { type: 'object', description: 'Action-specific payload' },
|
|
58
|
+
},
|
|
59
|
+
additionalProperties: true,
|
|
60
|
+
}
|
|
61
|
+
const full = jsonSchemaToZod(capabilitySchema)
|
|
62
|
+
expect(full.parse({ year: 2026, monthIndex: 7 })).toEqual({
|
|
63
|
+
year: 2026,
|
|
64
|
+
monthIndex: 7,
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
// MCP SDK wraps raw shapes with z.object(shape) — that path strips extras.
|
|
68
|
+
const stripped = z.object(jsonSchemaToZodShape(capabilitySchema)).parse({
|
|
69
|
+
year: 2026,
|
|
70
|
+
monthIndex: 7,
|
|
71
|
+
payload: {},
|
|
72
|
+
})
|
|
73
|
+
expect(stripped).toEqual({ payload: {} })
|
|
74
|
+
expect(stripped.year).toBeUndefined()
|
|
75
|
+
})
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
describe('normalizeMcpActionArgs', () => {
|
|
79
|
+
it('flattens nested payload bags used by default MCP envelopes', () => {
|
|
80
|
+
expect(normalizeMcpActionArgs(
|
|
81
|
+
{ payload: { year: 2026, monthIndex: 7 } },
|
|
82
|
+
{ workspaceId: 'ws-1', userId: 'u-1' },
|
|
83
|
+
)).toEqual({
|
|
84
|
+
workspaceId: 'ws-1',
|
|
85
|
+
user: undefined,
|
|
86
|
+
userId: 'u-1',
|
|
87
|
+
year: 2026,
|
|
88
|
+
monthIndex: 7,
|
|
89
|
+
})
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('keeps flat tool args (resources create style)', () => {
|
|
93
|
+
expect(normalizeMcpActionArgs(
|
|
94
|
+
{ location: '/demo/', name: 'Ada', type: '@ossy/demo/schema/author' },
|
|
95
|
+
{ workspaceId: 'ws-1' },
|
|
96
|
+
)).toMatchObject({
|
|
97
|
+
workspaceId: 'ws-1',
|
|
98
|
+
location: '/demo/',
|
|
99
|
+
name: 'Ada',
|
|
100
|
+
type: '@ossy/demo/schema/author',
|
|
101
|
+
})
|
|
102
|
+
})
|
|
103
|
+
})
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { buildCapabilities } from '@ossy/manifest/build-capabilities'
|
|
2
2
|
import { mountOssyMcp } from './mount-ossy-mcp.js'
|
|
3
3
|
import { uploadFileToolHandler, UPLOAD_FILE_TOOL } from './upload-file-tool.js'
|
|
4
|
+
import { normalizeMcpActionArgs } from './normalize-mcp-action-args.js'
|
|
4
5
|
import { ActionService } from '../actions/action.service.js'
|
|
5
6
|
import { TaskService } from '../tasks/task-service.js'
|
|
6
7
|
import { IntegrationService } from '../integration.service.js'
|
|
@@ -68,12 +69,7 @@ export function mountPlatformMcp (app, {
|
|
|
68
69
|
|
|
69
70
|
const actionLog = createLogger(actionId)
|
|
70
71
|
return ActionService.invoke(actionId, {
|
|
71
|
-
payload:
|
|
72
|
-
workspaceId: payload?.workspaceId ?? req?.workspaceId,
|
|
73
|
-
user: req?.user,
|
|
74
|
-
userId: req?.userId,
|
|
75
|
-
...payload,
|
|
76
|
-
},
|
|
72
|
+
payload: normalizeMcpActionArgs(payload, req),
|
|
77
73
|
sdk: req?.sdk ?? null,
|
|
78
74
|
log: actionLog,
|
|
79
75
|
integrations: IntegrationService,
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Flatten MCP tool args into the action payload shape tasks expect.
|
|
3
|
+
*
|
|
4
|
+
* Default capability envelopes expose a nested `payload` bag. Agents often send
|
|
5
|
+
* `{ payload: { year, monthIndex } }`, but tasks read `payload.year` after
|
|
6
|
+
* ActionService spreads the tool args — without flattening that becomes
|
|
7
|
+
* `payload.payload.year`.
|
|
8
|
+
*
|
|
9
|
+
* @param {object} [args]
|
|
10
|
+
* @param {{ workspaceId?: string, user?: object, userId?: string }} [req]
|
|
11
|
+
* @returns {object}
|
|
12
|
+
*/
|
|
13
|
+
export function normalizeMcpActionArgs (args = {}, req = {}) {
|
|
14
|
+
const { workspaceId, payload: nestedPayload, ...rest } = args ?? {}
|
|
15
|
+
const fromNested = (
|
|
16
|
+
nestedPayload
|
|
17
|
+
&& typeof nestedPayload === 'object'
|
|
18
|
+
&& !Array.isArray(nestedPayload)
|
|
19
|
+
)
|
|
20
|
+
? nestedPayload
|
|
21
|
+
: {}
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
workspaceId: workspaceId ?? req?.workspaceId,
|
|
25
|
+
user: req?.user,
|
|
26
|
+
userId: req?.userId,
|
|
27
|
+
...fromNested,
|
|
28
|
+
...rest,
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { readFile, stat } from 'node:fs/promises'
|
|
2
|
+
import { isLocalStorageUploadUrl } from '../storage/local-storage-url.js'
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Composite upload: create binary resource + PUT file bytes.
|
|
@@ -29,11 +30,18 @@ export async function uploadFileToolHandler (invokeAction, args, req) {
|
|
|
29
30
|
throw new Error('Storage is not configured or create did not return uploadUrl')
|
|
30
31
|
}
|
|
31
32
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
33
|
+
/** @type {Record<string, string>} */
|
|
34
|
+
const headers = { 'Content-Type': type }
|
|
35
|
+
const init = { method: 'PUT', headers, body }
|
|
36
|
+
if (isLocalStorageUploadUrl(uploadUrl)) {
|
|
37
|
+
init.credentials = 'include'
|
|
38
|
+
const workspaceId = args.workspaceId || req?.workspaceId
|
|
39
|
+
if (workspaceId) headers.workspaceId = workspaceId
|
|
40
|
+
const auth = req?.get?.('Authorization') || req?.signedCookies?.auth
|
|
41
|
+
if (auth) headers.Authorization = auth
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const response = await fetch(uploadUrl, init)
|
|
37
45
|
|
|
38
46
|
if (!response.ok) {
|
|
39
47
|
throw new Error(`Upload failed: HTTP ${response.status}`)
|
package/src/runtime.js
CHANGED
|
@@ -78,7 +78,8 @@ export async function startRuntime ({ port } = {}) {
|
|
|
78
78
|
|
|
79
79
|
const app = express()
|
|
80
80
|
// Liveness for ALB/ECS — before site loading so probes never depend on CMS/domain.
|
|
81
|
-
|
|
81
|
+
// Prefer OSSY_SERVICE_NAME (ECS task env) when set.
|
|
82
|
+
mountHealthEndpoint(app)
|
|
82
83
|
app.use(morgan('tiny'))
|
|
83
84
|
app.use(express.json({ strict: false }))
|
|
84
85
|
app.use(cookieParser(process.env.OSSY_COOKIE_SECRET || 'default_secret'))
|
package/src/server.js
CHANGED
|
@@ -276,11 +276,12 @@ export async function startServer (options = {}) {
|
|
|
276
276
|
const layoutsById = await loadLayoutsById(layoutManifest, buildDir, log)
|
|
277
277
|
|
|
278
278
|
// Register the SDK so all tasks receive it as `sdk`.
|
|
279
|
-
// Priority: explicit options.sdk → SDK
|
|
279
|
+
// Priority: explicit options.sdk → HTTP bot SDK (API_URL + OSSY_API_KEY) → in-process ActionService SDK.
|
|
280
280
|
const botSdk = (process.env.API_URL && process.env.OSSY_API_KEY)
|
|
281
281
|
? SDK.of({ apiUrl: process.env.API_URL, authorization: process.env.OSSY_API_KEY })
|
|
282
282
|
: null
|
|
283
|
-
|
|
283
|
+
const { createInProcessSdk } = await import('./tasks/in-process-sdk.js')
|
|
284
|
+
TaskService.setSdk(options.sdk ?? botSdk ?? createInProcessSdk())
|
|
284
285
|
|
|
285
286
|
TaskService.startScheduler()
|
|
286
287
|
|
|
@@ -322,7 +323,8 @@ export async function startServer (options = {}) {
|
|
|
322
323
|
|
|
323
324
|
const app = express()
|
|
324
325
|
// Liveness for ALB/ECS — before auth so probes never depend on cookies/Mongo.
|
|
325
|
-
|
|
326
|
+
// Prefer OSSY_SERVICE_NAME (ECS task env) when set.
|
|
327
|
+
mountHealthEndpoint(app)
|
|
326
328
|
app.use(morgan('tiny'))
|
|
327
329
|
app.use(express.json({ strict: false }))
|
|
328
330
|
app.use(cookieParser(ConfigService.TokenSecret))
|
|
@@ -342,6 +344,10 @@ export async function startServer (options = {}) {
|
|
|
342
344
|
next()
|
|
343
345
|
})
|
|
344
346
|
app.use(WorkspacesMiddleware.ExtractWorkspaceId())
|
|
347
|
+
// App-authored Express middleware from `src/middleware.js` (bundled to
|
|
348
|
+
// build/public/static/middleware.js). Runs after auth/workspace so handlers
|
|
349
|
+
// can rely on req.userId / req.workspaceId when present.
|
|
350
|
+
for (const mw of await loadAppMiddleware(buildDir)) app.use(mw)
|
|
345
351
|
app.use(createSlowRequestLogger(log))
|
|
346
352
|
if (fs.existsSync(publicDir)) app.use(express.static(publicDir))
|
|
347
353
|
app.use(ProxyInternal())
|
|
@@ -625,6 +631,32 @@ export async function startServer (options = {}) {
|
|
|
625
631
|
return { app, server, port, close: closeServer, lifetime }
|
|
626
632
|
}
|
|
627
633
|
|
|
634
|
+
/**
|
|
635
|
+
* Load optional app middleware bundled from `src/middleware.js`.
|
|
636
|
+
* Accepts a default export that is either a middleware function or an array.
|
|
637
|
+
*
|
|
638
|
+
* @param {string} buildDir
|
|
639
|
+
* @returns {Promise<import('express').RequestHandler[]>}
|
|
640
|
+
*/
|
|
641
|
+
async function loadAppMiddleware (buildDir) {
|
|
642
|
+
const candidates = [
|
|
643
|
+
path.resolve(buildDir, 'public', 'static', 'middleware.js'),
|
|
644
|
+
path.resolve(buildDir, 'middleware.js'),
|
|
645
|
+
]
|
|
646
|
+
for (const candidate of candidates) {
|
|
647
|
+
if (!fs.existsSync(candidate)) continue
|
|
648
|
+
try {
|
|
649
|
+
const mod = await import(pathToFileURL(candidate).href)
|
|
650
|
+
const value = mod.default
|
|
651
|
+
if (Array.isArray(value)) return value
|
|
652
|
+
if (typeof value === 'function') return [value]
|
|
653
|
+
} catch (err) {
|
|
654
|
+
log.warn(`Failed to load app middleware from ${candidate}`, undefined, err)
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
return []
|
|
658
|
+
}
|
|
659
|
+
|
|
628
660
|
export default startServer
|
|
629
661
|
export { loadLayoutsById, resolvePageLayoutRender }
|
|
630
662
|
export { ConfigService } from './config.service.js'
|
|
@@ -632,6 +664,7 @@ export { ActionService } from './actions/action.service.js'
|
|
|
632
664
|
export { IntegrationService } from './integration.service.js'
|
|
633
665
|
export { StorageClient } from './storage/storage.client.js'
|
|
634
666
|
export { originalObjectKey, derivativeObjectKey, assertStorageKey } from './storage/storage-keys.js'
|
|
667
|
+
export { isLocalStorageUploadUrl } from './storage/local-storage-url.js'
|
|
635
668
|
export { S3Client } from './storage/s3.client.js'
|
|
636
669
|
export { LocalStorageClient } from './storage/local-storage.client.js'
|
|
637
670
|
export { getSystemSchemas } from './resources/schema.registry.js'
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detect filesystem-backend upload URLs (`/local-storage?key=…`).
|
|
3
|
+
* S3 presigned URLs must not receive session cookies or Authorization headers.
|
|
4
|
+
*
|
|
5
|
+
* @param {string} url
|
|
6
|
+
* @returns {boolean}
|
|
7
|
+
*/
|
|
8
|
+
export function isLocalStorageUploadUrl (url) {
|
|
9
|
+
if (!url || typeof url !== 'string') return false
|
|
10
|
+
try {
|
|
11
|
+
const parsed = new URL(url, 'http://localhost')
|
|
12
|
+
return parsed.pathname === '/local-storage' || parsed.pathname.endsWith('/local-storage')
|
|
13
|
+
} catch {
|
|
14
|
+
return false
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { describe, expect, it } from '@jest/globals'
|
|
2
|
+
import { isLocalStorageUploadUrl } from './local-storage-url.js'
|
|
3
|
+
|
|
4
|
+
describe('isLocalStorageUploadUrl', () => {
|
|
5
|
+
it('matches relative and absolute local-storage paths', () => {
|
|
6
|
+
expect(isLocalStorageUploadUrl('/local-storage?key=abc')).toBe(true)
|
|
7
|
+
expect(isLocalStorageUploadUrl('http://localhost:3006/local-storage?key=abc')).toBe(true)
|
|
8
|
+
expect(isLocalStorageUploadUrl('https://app.example.com/local-storage?key=id%3Athumb')).toBe(true)
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
it('rejects S3 and unrelated URLs', () => {
|
|
12
|
+
expect(isLocalStorageUploadUrl('https://bucket.s3.amazonaws.com/key?X-Amz-Signature=abc')).toBe(false)
|
|
13
|
+
expect(isLocalStorageUploadUrl('/r/abc')).toBe(false)
|
|
14
|
+
expect(isLocalStorageUploadUrl('')).toBe(false)
|
|
15
|
+
expect(isLocalStorageUploadUrl(null)).toBe(false)
|
|
16
|
+
})
|
|
17
|
+
})
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { resolveActionId } from '@ossy/sdk'
|
|
2
|
+
import { ActionService } from '../actions/action.service.js'
|
|
3
|
+
import { IntegrationService } from '../integration.service.js'
|
|
4
|
+
import { StorageClient } from '../storage/storage.client.js'
|
|
5
|
+
import { derivativeObjectKey } from '../storage/storage-keys.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @param {unknown} file
|
|
9
|
+
* @returns {Promise<Buffer>}
|
|
10
|
+
*/
|
|
11
|
+
async function fileToBuffer (file) {
|
|
12
|
+
if (Buffer.isBuffer(file)) return file
|
|
13
|
+
if (file instanceof Uint8Array) return Buffer.from(file)
|
|
14
|
+
if (typeof file?.arrayBuffer === 'function') {
|
|
15
|
+
return Buffer.from(await file.arrayBuffer())
|
|
16
|
+
}
|
|
17
|
+
throw new Error('[InProcessSdk] uploadNamedVersion expects a File, Blob, Buffer, or Uint8Array')
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Resolve actor/workspace from an eventstore document for nested action invokes.
|
|
22
|
+
*
|
|
23
|
+
* @param {object} [event]
|
|
24
|
+
* @returns {{ userId: string, workspaceId?: string }}
|
|
25
|
+
*/
|
|
26
|
+
function reqFromEvent (event) {
|
|
27
|
+
return {
|
|
28
|
+
userId: event?.createdBy
|
|
29
|
+
?? event?.payload?.createdBy
|
|
30
|
+
?? event?.payload?.userId
|
|
31
|
+
?? 'system',
|
|
32
|
+
workspaceId: event?.payload?.belongsTo
|
|
33
|
+
?? event?.belongsTo
|
|
34
|
+
?? event?.payload?.workspaceId,
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Same-process SDK for changestream / scheduled tasks when no HTTP bot
|
|
40
|
+
* credentials (`API_URL` + `OSSY_API_KEY`) are configured.
|
|
41
|
+
*
|
|
42
|
+
* Implements the surface media and follow-up tasks use: `invoke` and
|
|
43
|
+
* `resources.uploadNamedVersion`, via ActionService + StorageClient.
|
|
44
|
+
*
|
|
45
|
+
* @param {{ req?: { userId?: string, workspaceId?: string } }} [options]
|
|
46
|
+
*/
|
|
47
|
+
export function createInProcessSdk (options = {}) {
|
|
48
|
+
const req = options.req ?? { userId: 'system' }
|
|
49
|
+
|
|
50
|
+
const sdk = {
|
|
51
|
+
/**
|
|
52
|
+
* @param {import('@ossy/sdk').ActionRef} action
|
|
53
|
+
* @param {Record<string, unknown>} [payload]
|
|
54
|
+
*/
|
|
55
|
+
invoke (action, payload = {}) {
|
|
56
|
+
const actionId = resolveActionId(action)
|
|
57
|
+
return ActionService.invoke(actionId, {
|
|
58
|
+
payload,
|
|
59
|
+
sdk,
|
|
60
|
+
integrations: IntegrationService,
|
|
61
|
+
req,
|
|
62
|
+
})
|
|
63
|
+
},
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Bind actor/workspace from a triggering event for one dispatch.
|
|
67
|
+
* @param {object} event
|
|
68
|
+
*/
|
|
69
|
+
withEventContext (event) {
|
|
70
|
+
return createInProcessSdk({ req: reqFromEvent(event) })
|
|
71
|
+
},
|
|
72
|
+
|
|
73
|
+
get resources () {
|
|
74
|
+
return {
|
|
75
|
+
/**
|
|
76
|
+
* Patch named derivative metadata, then write bytes to storage.
|
|
77
|
+
* @param {{ id: string, name: string, file: File | Blob | Buffer | Uint8Array }} args
|
|
78
|
+
*/
|
|
79
|
+
async uploadNamedVersion ({ id, name, file }) {
|
|
80
|
+
if (!id) throw new Error('[InProcessSdk] uploadNamedVersion requires id')
|
|
81
|
+
if (!name) throw new Error('[InProcessSdk] uploadNamedVersion requires name')
|
|
82
|
+
if (!file) throw new Error('[InProcessSdk] uploadNamedVersion requires file')
|
|
83
|
+
|
|
84
|
+
const size = typeof file.size === 'number' ? file.size : undefined
|
|
85
|
+
const type = typeof file.type === 'string' ? file.type : undefined
|
|
86
|
+
const buffer = await fileToBuffer(file)
|
|
87
|
+
|
|
88
|
+
const resource = await sdk.invoke('@ossy/resources/actions/upload-named-version', {
|
|
89
|
+
id,
|
|
90
|
+
namedVersion: name,
|
|
91
|
+
// HTTP SDK historically sent `name`; accept either in the task.
|
|
92
|
+
name,
|
|
93
|
+
size: size ?? buffer.length,
|
|
94
|
+
type,
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
// Action response may rewrite `sizes` to read URLs via withResourceMedia;
|
|
98
|
+
// always persist with the canonical derivative key.
|
|
99
|
+
await StorageClient.save(derivativeObjectKey(id, name), buffer)
|
|
100
|
+
return resource
|
|
101
|
+
},
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return sdk
|
|
107
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { describe, expect, it, jest } from '@jest/globals'
|
|
2
|
+
|
|
3
|
+
const invokeMock = jest.fn(async (_id, context) => ({
|
|
4
|
+
id: context.payload.id,
|
|
5
|
+
content: {
|
|
6
|
+
sizes: { [context.payload.namedVersion]: `${context.payload.id}:${context.payload.namedVersion}` },
|
|
7
|
+
uploadUrl: '/local-storage?key=unused',
|
|
8
|
+
},
|
|
9
|
+
}))
|
|
10
|
+
|
|
11
|
+
const saveMock = jest.fn(async () => {})
|
|
12
|
+
|
|
13
|
+
jest.unstable_mockModule('../actions/action.service.js', () => ({
|
|
14
|
+
ActionService: { invoke: invokeMock },
|
|
15
|
+
}))
|
|
16
|
+
|
|
17
|
+
jest.unstable_mockModule('../integration.service.js', () => ({
|
|
18
|
+
IntegrationService: { get: () => null },
|
|
19
|
+
}))
|
|
20
|
+
|
|
21
|
+
jest.unstable_mockModule('../storage/storage.client.js', () => ({
|
|
22
|
+
StorageClient: { save: saveMock },
|
|
23
|
+
}))
|
|
24
|
+
|
|
25
|
+
const { createInProcessSdk } = await import('./in-process-sdk.js')
|
|
26
|
+
|
|
27
|
+
describe('createInProcessSdk', () => {
|
|
28
|
+
it('invokes actions in-process with bound req', async () => {
|
|
29
|
+
invokeMock.mockClear()
|
|
30
|
+
const sdk = createInProcessSdk({
|
|
31
|
+
req: { userId: 'u1', workspaceId: 'ws1' },
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
await sdk.invoke({ id: '@ossy/resources/actions/get' }, { resourceId: 'r1' })
|
|
35
|
+
|
|
36
|
+
expect(invokeMock).toHaveBeenCalledWith(
|
|
37
|
+
'@ossy/resources/actions/get',
|
|
38
|
+
expect.objectContaining({
|
|
39
|
+
payload: { resourceId: 'r1' },
|
|
40
|
+
req: { userId: 'u1', workspaceId: 'ws1' },
|
|
41
|
+
}),
|
|
42
|
+
)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('binds event actor/workspace via withEventContext', async () => {
|
|
46
|
+
invokeMock.mockClear()
|
|
47
|
+
const sdk = createInProcessSdk().withEventContext({
|
|
48
|
+
createdBy: 'author',
|
|
49
|
+
payload: { belongsTo: 'workspace-a' },
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
await sdk.invoke('@ossy/resources/actions/get', { resourceId: 'r2' })
|
|
53
|
+
|
|
54
|
+
expect(invokeMock).toHaveBeenCalledWith(
|
|
55
|
+
'@ossy/resources/actions/get',
|
|
56
|
+
expect.objectContaining({
|
|
57
|
+
req: { userId: 'author', workspaceId: 'workspace-a' },
|
|
58
|
+
}),
|
|
59
|
+
)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('uploadNamedVersion patches then saves bytes', async () => {
|
|
63
|
+
invokeMock.mockClear()
|
|
64
|
+
saveMock.mockClear()
|
|
65
|
+
const sdk = createInProcessSdk()
|
|
66
|
+
const bytes = new Uint8Array([1, 2, 3, 4])
|
|
67
|
+
|
|
68
|
+
await sdk.resources.uploadNamedVersion({
|
|
69
|
+
id: 'file1',
|
|
70
|
+
name: 'thumbnailSmall',
|
|
71
|
+
file: new File([bytes], 'thumbnailSmall', { type: 'image/jpeg' }),
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
expect(invokeMock).toHaveBeenCalledWith(
|
|
75
|
+
'@ossy/resources/actions/upload-named-version',
|
|
76
|
+
expect.objectContaining({
|
|
77
|
+
payload: expect.objectContaining({
|
|
78
|
+
id: 'file1',
|
|
79
|
+
namedVersion: 'thumbnailSmall',
|
|
80
|
+
type: 'image/jpeg',
|
|
81
|
+
}),
|
|
82
|
+
}),
|
|
83
|
+
)
|
|
84
|
+
expect(saveMock).toHaveBeenCalledWith(
|
|
85
|
+
'file1:thumbnailSmall',
|
|
86
|
+
expect.any(Buffer),
|
|
87
|
+
)
|
|
88
|
+
})
|
|
89
|
+
})
|
|
@@ -147,7 +147,10 @@ export class TaskService {
|
|
|
147
147
|
static dispatch(event, { sdk } = {}) {
|
|
148
148
|
if (!event) return
|
|
149
149
|
|
|
150
|
-
const
|
|
150
|
+
const baseSdk = sdk ?? TaskService._sdk
|
|
151
|
+
const effectiveSdk = typeof baseSdk?.withEventContext === 'function'
|
|
152
|
+
? baseSdk.withEventContext(event)
|
|
153
|
+
: baseSdk
|
|
151
154
|
|
|
152
155
|
for (const { metadata, handler } of TaskService._tasks) {
|
|
153
156
|
const triggers = metadata.triggers ?? []
|
package/src/test/flow-runner.js
CHANGED
|
@@ -246,10 +246,16 @@ export async function runFlow (flow, options = {}) {
|
|
|
246
246
|
}
|
|
247
247
|
const engine = schemaEngineFromManifest(manifest)
|
|
248
248
|
const content = mockFormContent(engine, template, context)
|
|
249
|
+
const formTimeout = step.timeout ?? 15000
|
|
249
250
|
const formRoot = formId ? page.locator(`form[id="${formId}"]`) : page
|
|
251
|
+
await formRoot.waitFor({ state: 'visible', timeout: formTimeout })
|
|
250
252
|
for (const field of template.fields) {
|
|
251
253
|
const value = content[field.name]
|
|
252
|
-
|
|
254
|
+
if (value === undefined || value === null) {
|
|
255
|
+
throw new Error(`mockFormContent produced no value for field "${field.name}"`)
|
|
256
|
+
}
|
|
257
|
+
const locator = formRoot.locator(`[name="${field.name}"]`).first()
|
|
258
|
+
await locator.waitFor({ state: 'visible', timeout: formTimeout })
|
|
253
259
|
const tag = await locator.evaluate(el => el.tagName.toLowerCase()).catch(() => 'input')
|
|
254
260
|
if (tag === 'select') {
|
|
255
261
|
await locator.selectOption(String(value))
|
|
@@ -258,7 +264,18 @@ export async function runFlow (flow, options = {}) {
|
|
|
258
264
|
if (inputType === 'checkbox') {
|
|
259
265
|
if (value) await locator.check()
|
|
260
266
|
} else {
|
|
261
|
-
|
|
267
|
+
// Retry fills: early hydration can accept DOM input then wipe on re-render.
|
|
268
|
+
const asText = String(value)
|
|
269
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
270
|
+
await locator.fill(asText)
|
|
271
|
+
try {
|
|
272
|
+
await expectFn(locator).toHaveValue(asText, { timeout: 2000 })
|
|
273
|
+
break
|
|
274
|
+
} catch (err) {
|
|
275
|
+
if (attempt === 2) throw err
|
|
276
|
+
await page.waitForTimeout(150)
|
|
277
|
+
}
|
|
278
|
+
}
|
|
262
279
|
}
|
|
263
280
|
} else {
|
|
264
281
|
await locator.fill(String(value))
|
|
@@ -271,10 +288,26 @@ export async function runFlow (flow, options = {}) {
|
|
|
271
288
|
if (!page) throw new Error('action step requires a Playwright page')
|
|
272
289
|
const actionId = resolveActionId(step.action)
|
|
273
290
|
const service = resolveActionService(step.action, step.service)
|
|
274
|
-
const
|
|
291
|
+
const matches = page.locator(actionSelector(actionId, service))
|
|
275
292
|
const actionTimeout = step.timeout ?? 15000
|
|
276
|
-
await
|
|
277
|
-
|
|
293
|
+
await matches.first().waitFor({ state: 'attached', timeout: actionTimeout })
|
|
294
|
+
// Prefer an actionable match when duplicates exist (e.g. hero CTA under a page overlay).
|
|
295
|
+
const count = await matches.count()
|
|
296
|
+
let clicked = false
|
|
297
|
+
let lastError
|
|
298
|
+
for (let i = 0; i < count; i++) {
|
|
299
|
+
const candidate = matches.nth(i)
|
|
300
|
+
try {
|
|
301
|
+
await candidate.click({ timeout: Math.min(5000, actionTimeout) })
|
|
302
|
+
clicked = true
|
|
303
|
+
break
|
|
304
|
+
} catch (err) {
|
|
305
|
+
lastError = err
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
if (!clicked) {
|
|
309
|
+
throw lastError ?? new Error(`No actionable locator for ${actionSelector(actionId, service)}`)
|
|
310
|
+
}
|
|
278
311
|
continue
|
|
279
312
|
}
|
|
280
313
|
|
|
@@ -17,8 +17,16 @@
|
|
|
17
17
|
/** @type {import('@playwright/test').PlaywrightTestConfig} */
|
|
18
18
|
export default {
|
|
19
19
|
retries: process.env.CI ? 1 : 0,
|
|
20
|
+
// Fail hung flow steps instead of sitting on the default 30s forever across many actions.
|
|
21
|
+
timeout: process.env.CI ? 60_000 : 30_000,
|
|
22
|
+
expect: {
|
|
23
|
+
timeout: process.env.CI ? 15_000 : 5_000,
|
|
24
|
+
},
|
|
20
25
|
use: {
|
|
21
26
|
baseURL: process.env.OSSY_APP_URL ?? process.env.BASE_URL ?? 'http://localhost:3002',
|
|
27
|
+
trace: process.env.CI ? 'on-first-retry' : 'off',
|
|
28
|
+
actionTimeout: process.env.CI ? 15_000 : 0,
|
|
29
|
+
navigationTimeout: process.env.CI ? 30_000 : 0,
|
|
22
30
|
},
|
|
23
31
|
reporter: [['list']],
|
|
24
32
|
env: {
|
package/src/token.service.js
CHANGED
|
@@ -24,7 +24,7 @@ export class TokenService {
|
|
|
24
24
|
|
|
25
25
|
if (!token) {
|
|
26
26
|
log.debug('[TokenService] No token to verify')
|
|
27
|
-
return reject(new Error('No token'))
|
|
27
|
+
return reject(Object.assign(new Error('No token'), { status: 401 }))
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
jwt.verify(token, ConfigService.TokenSecret, { algorithms: ['HS256'] }, (error, payload) => {
|
|
@@ -32,7 +32,12 @@ export class TokenService {
|
|
|
32
32
|
|
|
33
33
|
if (errorType) {
|
|
34
34
|
log.error('[TokenService]: Token invalid', undefined, error)
|
|
35
|
-
|
|
35
|
+
// Callers (e.g. POST /actions) use `err.status ?? 500` — always attach 401
|
|
36
|
+
// so unverifiable auth tokens are not reported as internal errors (#494).
|
|
37
|
+
return reject(Object.assign(new Error('Invalid or expired token'), {
|
|
38
|
+
status: 401,
|
|
39
|
+
cause: error,
|
|
40
|
+
}))
|
|
36
41
|
}
|
|
37
42
|
|
|
38
43
|
log.info('[TokenService]: Token verified')
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { beforeAll, describe, expect, it } from '@jest/globals'
|
|
2
|
+
import jwt from 'jsonwebtoken'
|
|
3
|
+
import { TokenService } from './token.service.js'
|
|
4
|
+
import { ConfigService } from './config.service.js'
|
|
5
|
+
|
|
6
|
+
describe('TokenService.verify', () => {
|
|
7
|
+
beforeAll(() => {
|
|
8
|
+
process.env.TOKEN_SECRET = process.env.TOKEN_SECRET || 'test-token-secret-for-verify'
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
it('resolves a valid HS256 token payload', async () => {
|
|
12
|
+
const token = jwt.sign({ sub: 'user-1', type: 'WebAuth' }, ConfigService.TokenSecret, {
|
|
13
|
+
algorithm: 'HS256',
|
|
14
|
+
expiresIn: '1h',
|
|
15
|
+
})
|
|
16
|
+
await expect(TokenService.verify(token)).resolves.toMatchObject({
|
|
17
|
+
sub: 'user-1',
|
|
18
|
+
type: 'WebAuth',
|
|
19
|
+
})
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('rejects missing tokens with status 401', async () => {
|
|
23
|
+
await expect(TokenService.verify('')).rejects.toMatchObject({
|
|
24
|
+
message: 'No token',
|
|
25
|
+
status: 401,
|
|
26
|
+
})
|
|
27
|
+
await expect(TokenService.verify(null)).rejects.toMatchObject({
|
|
28
|
+
status: 401,
|
|
29
|
+
})
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('rejects garbage tokens with status 401 (not an empty rejection)', async () => {
|
|
33
|
+
await expect(TokenService.verify('not-a-jwt')).rejects.toMatchObject({
|
|
34
|
+
message: 'Invalid or expired token',
|
|
35
|
+
status: 401,
|
|
36
|
+
})
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('rejects expired tokens with status 401', async () => {
|
|
40
|
+
const token = jwt.sign({ sub: 'user-1' }, ConfigService.TokenSecret, {
|
|
41
|
+
algorithm: 'HS256',
|
|
42
|
+
expiresIn: -10,
|
|
43
|
+
})
|
|
44
|
+
await expect(TokenService.verify(token)).rejects.toMatchObject({
|
|
45
|
+
message: 'Invalid or expired token',
|
|
46
|
+
status: 401,
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
})
|
package/src/users.middleware.js
CHANGED
|
@@ -26,21 +26,31 @@ function normalizeAuthToken (token) {
|
|
|
26
26
|
*/
|
|
27
27
|
export class UsersMiddleware {
|
|
28
28
|
|
|
29
|
-
/**
|
|
30
|
-
|
|
31
|
-
|
|
29
|
+
/**
|
|
30
|
+
* Reject revoked session/API tokens (JWT alone stays valid until expiry).
|
|
31
|
+
* WebAuth tokens with `jti` are checked after sign-out (#359); API tokens
|
|
32
|
+
* keep the same revoke path. Legacy WebAuth JWTs without `jti` skip this.
|
|
33
|
+
*/
|
|
34
|
+
static assertAuthTokenActive(payload) {
|
|
35
|
+
if ((payload.type !== 'Api' && payload.type !== 'WebAuth') || !payload.jti) {
|
|
32
36
|
return Promise.resolve(payload)
|
|
33
37
|
}
|
|
34
38
|
return Aggregate.Of(Token, payload.jti)
|
|
35
39
|
.then(aggregate => {
|
|
36
40
|
const view = Token.View(aggregate.events, aggregate.state)
|
|
37
41
|
if (view.status === 'Revoked') {
|
|
38
|
-
|
|
42
|
+
const label = payload.type === 'Api' ? 'Api token revoked' : 'Auth token revoked'
|
|
43
|
+
return Promise.reject(Object.assign(new Error(label), { status: 401 }))
|
|
39
44
|
}
|
|
40
45
|
return payload
|
|
41
46
|
})
|
|
42
47
|
}
|
|
43
48
|
|
|
49
|
+
/** @deprecated Use assertAuthTokenActive — kept for callers that still import the old name. */
|
|
50
|
+
static assertApiTokenActive(payload) {
|
|
51
|
+
return UsersMiddleware.assertAuthTokenActive(payload)
|
|
52
|
+
}
|
|
53
|
+
|
|
44
54
|
/**
|
|
45
55
|
* Resolves the caller to a real user (with workspaces and policies attached)
|
|
46
56
|
* or to the anonymous principal. Authorization / role checks belong in a separate step.
|
|
@@ -74,7 +84,7 @@ export class UsersMiddleware {
|
|
|
74
84
|
|
|
75
85
|
withTimeout(
|
|
76
86
|
TokenService.verify(authToken)
|
|
77
|
-
.then(UsersMiddleware.
|
|
87
|
+
.then(UsersMiddleware.assertAuthTokenActive)
|
|
78
88
|
.then(payload => {
|
|
79
89
|
req.authPayload = payload
|
|
80
90
|
req.tokenScopes = payload?.type === 'Api' ? (payload.scopes ?? ['*']) : null
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, jest } from '@jest/globals'
|
|
2
|
+
|
|
3
|
+
const ofMock = jest.fn()
|
|
4
|
+
|
|
5
|
+
jest.unstable_mockModule('@ossy/event-store', () => ({
|
|
6
|
+
Aggregate: {
|
|
7
|
+
Of: ofMock,
|
|
8
|
+
},
|
|
9
|
+
}))
|
|
10
|
+
|
|
11
|
+
jest.unstable_mockModule('@ossy/observability', () => ({
|
|
12
|
+
createLogger: () => ({
|
|
13
|
+
debug () {},
|
|
14
|
+
info () {},
|
|
15
|
+
warn () {},
|
|
16
|
+
error () {},
|
|
17
|
+
}),
|
|
18
|
+
}))
|
|
19
|
+
|
|
20
|
+
jest.unstable_mockModule('@ossy/users/server', () => ({
|
|
21
|
+
User: { name: 'User' },
|
|
22
|
+
}))
|
|
23
|
+
|
|
24
|
+
jest.unstable_mockModule('@ossy/tokens/server', () => ({
|
|
25
|
+
Token: {
|
|
26
|
+
View (events, state = {}) {
|
|
27
|
+
return events.reduce((token, event) => {
|
|
28
|
+
if (event.event === 'Created') {
|
|
29
|
+
return { ...event.payload, status: 'Active', id: event.resourceId }
|
|
30
|
+
}
|
|
31
|
+
if (event.event === 'Revoked') {
|
|
32
|
+
return { ...token, status: 'Revoked' }
|
|
33
|
+
}
|
|
34
|
+
return token
|
|
35
|
+
}, state)
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
}))
|
|
39
|
+
|
|
40
|
+
jest.unstable_mockModule('@ossy/policies/server', () => ({
|
|
41
|
+
PoliciesQueries: { GetPoliciesForUser: async () => [] },
|
|
42
|
+
}))
|
|
43
|
+
|
|
44
|
+
jest.unstable_mockModule('./config.service.js', () => ({
|
|
45
|
+
ConfigService: { AnonymousUserId: 'anonymous' },
|
|
46
|
+
}))
|
|
47
|
+
|
|
48
|
+
jest.unstable_mockModule('./token.service.js', () => ({
|
|
49
|
+
TokenService: { verify: async () => ({}) },
|
|
50
|
+
}))
|
|
51
|
+
|
|
52
|
+
jest.unstable_mockModule('./request-diagnostics.js', () => ({
|
|
53
|
+
OperationTimeoutError: class OperationTimeoutError extends Error {},
|
|
54
|
+
resolveTimeoutMs: () => 10_000,
|
|
55
|
+
withTimeout: (promise) => promise,
|
|
56
|
+
}))
|
|
57
|
+
|
|
58
|
+
const { UsersMiddleware } = await import('./users.middleware.js')
|
|
59
|
+
|
|
60
|
+
describe('UsersMiddleware.assertAuthTokenActive', () => {
|
|
61
|
+
beforeEach(() => {
|
|
62
|
+
ofMock.mockReset()
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('allows payloads without jti (legacy WebAuth)', async () => {
|
|
66
|
+
await expect(UsersMiddleware.assertAuthTokenActive({
|
|
67
|
+
type: 'WebAuth',
|
|
68
|
+
sub: 'user-1',
|
|
69
|
+
})).resolves.toEqual({ type: 'WebAuth', sub: 'user-1' })
|
|
70
|
+
expect(ofMock).not.toHaveBeenCalled()
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('allows active WebAuth tokens', async () => {
|
|
74
|
+
ofMock.mockResolvedValue({
|
|
75
|
+
events: [{ event: 'Created', resourceId: 'sess-1', payload: { type: 'WebAuth' } }],
|
|
76
|
+
state: {},
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
await expect(UsersMiddleware.assertAuthTokenActive({
|
|
80
|
+
type: 'WebAuth',
|
|
81
|
+
jti: 'sess-1',
|
|
82
|
+
sub: 'user-1',
|
|
83
|
+
})).resolves.toMatchObject({ jti: 'sess-1' })
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('rejects revoked WebAuth tokens with status 401', async () => {
|
|
87
|
+
ofMock.mockResolvedValue({
|
|
88
|
+
events: [
|
|
89
|
+
{ event: 'Created', resourceId: 'sess-2', payload: { type: 'WebAuth' } },
|
|
90
|
+
{ event: 'Revoked', payload: {} },
|
|
91
|
+
],
|
|
92
|
+
state: {},
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
await expect(UsersMiddleware.assertAuthTokenActive({
|
|
96
|
+
type: 'WebAuth',
|
|
97
|
+
jti: 'sess-2',
|
|
98
|
+
sub: 'user-1',
|
|
99
|
+
})).rejects.toMatchObject({
|
|
100
|
+
message: 'Auth token revoked',
|
|
101
|
+
status: 401,
|
|
102
|
+
})
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('rejects revoked API tokens', async () => {
|
|
106
|
+
ofMock.mockResolvedValue({
|
|
107
|
+
events: [
|
|
108
|
+
{ event: 'Created', resourceId: 'api-1', payload: { type: 'Api' } },
|
|
109
|
+
{ event: 'Revoked', payload: {} },
|
|
110
|
+
],
|
|
111
|
+
state: {},
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
await expect(UsersMiddleware.assertAuthTokenActive({
|
|
115
|
+
type: 'Api',
|
|
116
|
+
jti: 'api-1',
|
|
117
|
+
sub: 'user-1',
|
|
118
|
+
})).rejects.toMatchObject({
|
|
119
|
+
message: 'Api token revoked',
|
|
120
|
+
status: 401,
|
|
121
|
+
})
|
|
122
|
+
})
|
|
123
|
+
})
|