@ossy/platform 3.4.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 +2 -0
- package/docker-healthcheck.js +32 -0
- package/package.json +11 -10
- 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/server.js +1 -0
- package/src/storage/local-storage-url.js +16 -0
- package/src/storage/local-storage-url.spec.js +17 -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
|
@@ -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)",
|
|
@@ -47,14 +47,14 @@
|
|
|
47
47
|
"@ossy/config": "^3.0.9",
|
|
48
48
|
"@ossy/event-store": "^3.4.0",
|
|
49
49
|
"@ossy/locale": "^3.4.0",
|
|
50
|
-
"@ossy/manifest": "^3.
|
|
50
|
+
"@ossy/manifest": "^3.5.0",
|
|
51
51
|
"@ossy/observability": "^3.0.9",
|
|
52
52
|
"@ossy/policies": "^3.0.9",
|
|
53
|
-
"@ossy/schema": "^3.
|
|
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
|
}
|
|
@@ -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/server.js
CHANGED
|
@@ -664,6 +664,7 @@ export { ActionService } from './actions/action.service.js'
|
|
|
664
664
|
export { IntegrationService } from './integration.service.js'
|
|
665
665
|
export { StorageClient } from './storage/storage.client.js'
|
|
666
666
|
export { originalObjectKey, derivativeObjectKey, assertStorageKey } from './storage/storage-keys.js'
|
|
667
|
+
export { isLocalStorageUploadUrl } from './storage/local-storage-url.js'
|
|
667
668
|
export { S3Client } from './storage/s3.client.js'
|
|
668
669
|
export { LocalStorageClient } from './storage/local-storage.client.js'
|
|
669
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
|
+
})
|
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
|
+
})
|