@ossy/platform 1.39.2 → 1.39.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -17
- package/package.json +20 -11
- package/src/Definition.js +1 -0
- package/src/PlatformShell.jsx +10 -10
- package/src/actions/action.service.js +79 -6
- package/src/audit/action-invocation-service.js +144 -0
- package/src/audit/action-invocation.aggregate.js +59 -0
- package/src/audit/audit-helpers.js +61 -0
- package/src/audit/detect-channel.js +19 -0
- package/src/audit/list-task-runs.action.js +5 -0
- package/src/audit/list-task-runs.task.js +17 -0
- package/src/audit/task-run-list.aggregate.js +78 -0
- package/src/audit/task-run-service.js +193 -0
- package/src/audit/task-run.aggregate.js +60 -0
- package/src/auth/action-scopes.js +3 -0
- package/src/capability-schemas/action-capability.schema.js +1 -0
- package/src/capability-schemas/action-meta.schema.js +1 -0
- package/src/capability-schemas/component-capability.schema.js +1 -0
- package/src/capability-schemas/page-capability.schema.js +1 -0
- package/src/capability-schemas/page-meta.schema.js +1 -0
- package/src/capability-schemas/task-capability.schema.js +1 -0
- package/src/capability-schemas/task-meta.schema.js +1 -0
- package/src/capability-schemas/task-output.schema.js +1 -0
- package/src/capability-schemas/task-trigger-authoring.schema.js +1 -0
- package/src/capability-schemas/task-trigger.schema.js +1 -0
- package/src/directory.schema.js +9 -0
- package/src/entitlements/action-entitlement.js +69 -0
- package/src/file.schema.js +13 -0
- package/src/index.js +13 -3
- package/src/mcp/create-ossy-mcp-server.js +97 -0
- package/src/mcp/json-schema-to-zod.js +64 -0
- package/src/mcp/mount-ossy-mcp.js +72 -0
- package/src/mcp/mount-platform-mcp.js +101 -0
- package/src/mcp/upload-file-tool.js +62 -0
- package/src/metering/metering-service.js +92 -0
- package/src/{platform-config.resource.js → platform-config.schema.js} +1 -1
- package/src/proxy-internal.js +13 -16
- package/src/push/mount-push-sse.js +43 -0
- package/src/resources/index.js +3 -2
- package/src/resources/schema.registry.js +26 -0
- package/src/resources/schema.service.js +54 -0
- package/src/resources/schema.validation.js +90 -0
- package/src/runtime.js +4 -1
- package/src/schema-ids.js +9 -0
- package/src/server.js +121 -20
- package/src/storage/filesystem-storage.client.js +109 -0
- package/src/storage/local-storage.client.js +2 -65
- package/src/storage/resource-read-url.js +36 -0
- package/src/storage/resource-read-url.spec.js +22 -0
- package/src/storage/s3-storage.client.js +102 -0
- package/src/storage/s3.client.js +27 -23
- package/src/storage/storage-keys.js +37 -0
- package/src/storage/storage-keys.spec.js +16 -0
- package/src/storage/storage.client.js +52 -8
- package/src/storage/storage.integration.js +40 -0
- package/src/tasks/change-stream.js +9 -2
- package/src/tasks/task-service.js +211 -34
- package/src/tasks/task-service.spec.js +187 -0
- package/src/test/e2e.util.js +18 -39
- package/src/test/flow-runner.js +476 -0
- package/src/test/test.util.js +30 -29
- package/src/user-app-settings.js +44 -0
- package/src/users.middleware.js +14 -2
- package/src/resources/resource-template.registry.js +0 -29
- package/src/resources/resource-template.validation.js +0 -232
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { describe, expect, it } from '@jest/globals'
|
|
2
|
+
import { TaskService } from './task-service.js'
|
|
3
|
+
import { TaskRun } from '../audit/task-run.aggregate.js'
|
|
4
|
+
import { ActionInvocation } from '../audit/action-invocation.aggregate.js'
|
|
5
|
+
import { TaskRunListProjection } from '../audit/task-run-list.aggregate.js'
|
|
6
|
+
import { detectChannel } from '../audit/detect-channel.js'
|
|
7
|
+
import { hashPayload, moduleIdFromTaskId } from '../audit/audit-helpers.js'
|
|
8
|
+
import { isPrimaryTaskForAction, taskIdFromActionId } from '@ossy/schema'
|
|
9
|
+
|
|
10
|
+
describe('TaskService triggers', () => {
|
|
11
|
+
it('matches on_action triggers with glob patterns', () => {
|
|
12
|
+
expect(TaskService._matchesActionTrigger(
|
|
13
|
+
{ trigger: 'on_action', action: '@ossy/booking/actions/*' },
|
|
14
|
+
'@ossy/booking/actions/create',
|
|
15
|
+
)).toBe(true)
|
|
16
|
+
|
|
17
|
+
expect(TaskService._matchesActionTrigger(
|
|
18
|
+
{ trigger: 'on_action', action: '@ossy/booking/actions/*' },
|
|
19
|
+
'@ossy/users/actions/create',
|
|
20
|
+
)).toBe(false)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('ignores on_action triggers in event dispatch', () => {
|
|
24
|
+
const event = {
|
|
25
|
+
type: '@ossy/booking/schema/booking',
|
|
26
|
+
event: 'Created',
|
|
27
|
+
resourceId: 'b1',
|
|
28
|
+
payload: { belongsTo: 'ws1' },
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
expect(TaskService._matchesEventTrigger(
|
|
32
|
+
{ trigger: 'on_action', action: '@ossy/booking/actions/*' },
|
|
33
|
+
event,
|
|
34
|
+
)).toBe(false)
|
|
35
|
+
|
|
36
|
+
expect(TaskService._matchesEventTrigger(
|
|
37
|
+
{ type: '@ossy/booking/schema/booking', event: 'Created' },
|
|
38
|
+
event,
|
|
39
|
+
)).toBe(true)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('supports excludeTypes to skip platform audit envelopes', () => {
|
|
43
|
+
const taskRunEvent = {
|
|
44
|
+
type: '@ossy/platform/schema/task-run',
|
|
45
|
+
event: 'Started',
|
|
46
|
+
resourceId: 'run1',
|
|
47
|
+
payload: { belongsTo: 'ws1' },
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
expect(TaskService._matchesEventTrigger(
|
|
51
|
+
{ excludeTypes: ['@ossy/platform/schema/task-run'] },
|
|
52
|
+
taskRunEvent,
|
|
53
|
+
)).toBe(false)
|
|
54
|
+
|
|
55
|
+
expect(TaskService._matchesEventTrigger(
|
|
56
|
+
{ excludeTypes: ['@ossy/platform/schema/task-run'] },
|
|
57
|
+
{
|
|
58
|
+
type: '@ossy/booking/schema/booking',
|
|
59
|
+
event: 'Created',
|
|
60
|
+
resourceId: 'b1',
|
|
61
|
+
payload: { belongsTo: 'ws1' },
|
|
62
|
+
},
|
|
63
|
+
)).toBe(true)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('skips primary derived task in on_action dispatch', () => {
|
|
67
|
+
expect(isPrimaryTaskForAction('@ossy/booking/actions/create', '@ossy/booking/tasks/create')).toBe(true)
|
|
68
|
+
expect(isPrimaryTaskForAction('@ossy/booking/actions/create', '@ossy/booking/tasks/send-reminder')).toBe(false)
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
describe('capability id derivation', () => {
|
|
73
|
+
it('derives task id from action id', () => {
|
|
74
|
+
expect(taskIdFromActionId('@ossy/booking/actions/create')).toBe('@ossy/booking/tasks/create')
|
|
75
|
+
})
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
describe('TaskRun entity fold', () => {
|
|
79
|
+
it('derives status lifecycle from events', () => {
|
|
80
|
+
const started = {
|
|
81
|
+
resourceId: 'run1',
|
|
82
|
+
type: '@ossy/platform/schema/task-run',
|
|
83
|
+
event: 'Started',
|
|
84
|
+
created: 1000,
|
|
85
|
+
payload: {
|
|
86
|
+
taskId: '@ossy/booking/tasks/create',
|
|
87
|
+
moduleId: 'booking',
|
|
88
|
+
trigger: 'invoke',
|
|
89
|
+
belongsTo: 'ws1',
|
|
90
|
+
executionEnv: 'ossy_server',
|
|
91
|
+
},
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
let state = TaskRun.View([started])
|
|
95
|
+
expect(state.status).toBe('in progress')
|
|
96
|
+
expect(state.taskId).toBe('@ossy/booking/tasks/create')
|
|
97
|
+
|
|
98
|
+
state = TaskRun.View([
|
|
99
|
+
started,
|
|
100
|
+
{
|
|
101
|
+
event: 'Completed',
|
|
102
|
+
created: 2500,
|
|
103
|
+
payload: { durationMs: 1500, resultSummary: { kind: 'object', keyCount: 1 } },
|
|
104
|
+
},
|
|
105
|
+
])
|
|
106
|
+
expect(state.status).toBe('success')
|
|
107
|
+
expect(state.durationMs).toBe(1500)
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
describe('TaskRunList projection', () => {
|
|
112
|
+
it('lists runs per workspace and updates status', () => {
|
|
113
|
+
const started = {
|
|
114
|
+
resourceId: 'run1',
|
|
115
|
+
type: '@ossy/platform/schema/task-run',
|
|
116
|
+
event: 'Started',
|
|
117
|
+
created: 1000,
|
|
118
|
+
payload: {
|
|
119
|
+
taskId: '@ossy/media-tasks/tasks/resize',
|
|
120
|
+
moduleId: 'media-tasks',
|
|
121
|
+
trigger: 'on_event',
|
|
122
|
+
belongsTo: 'ws1',
|
|
123
|
+
},
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
let state = TaskRunListProjection.initialState('ws1')
|
|
127
|
+
state = TaskRunListProjection.Apply(started, state)
|
|
128
|
+
expect(state.runs).toHaveLength(1)
|
|
129
|
+
expect(state.runs[0].status).toBe('in progress')
|
|
130
|
+
|
|
131
|
+
state = TaskRunListProjection.Apply({
|
|
132
|
+
resourceId: 'run1',
|
|
133
|
+
event: 'Completed',
|
|
134
|
+
created: 2000,
|
|
135
|
+
payload: { durationMs: 1000, resultSummary: null },
|
|
136
|
+
}, state)
|
|
137
|
+
|
|
138
|
+
expect(state.runs[0].status).toBe('success')
|
|
139
|
+
expect(state.runs[0].durationMs).toBe(1000)
|
|
140
|
+
})
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
describe('ActionInvocation entity fold', () => {
|
|
144
|
+
it('records invoke lifecycle', () => {
|
|
145
|
+
const invoked = {
|
|
146
|
+
resourceId: 'inv1',
|
|
147
|
+
type: '@ossy/platform/schema/action-invocation',
|
|
148
|
+
event: 'Invoked',
|
|
149
|
+
created: 500,
|
|
150
|
+
payload: {
|
|
151
|
+
actionId: '@ossy/booking/actions/create',
|
|
152
|
+
moduleId: 'booking',
|
|
153
|
+
channel: 'mcp',
|
|
154
|
+
payloadHash: 'abc',
|
|
155
|
+
belongsTo: 'ws1',
|
|
156
|
+
},
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
let state = ActionInvocation.View([invoked])
|
|
160
|
+
expect(state.status).toBe('in progress')
|
|
161
|
+
expect(state.channel).toBe('mcp')
|
|
162
|
+
|
|
163
|
+
state = ActionInvocation.View([
|
|
164
|
+
invoked,
|
|
165
|
+
{ event: 'Completed', created: 700, payload: { durationMs: 200, taskRunId: 'run1' } },
|
|
166
|
+
])
|
|
167
|
+
expect(state.status).toBe('success')
|
|
168
|
+
expect(state.taskRunId).toBe('run1')
|
|
169
|
+
})
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
describe('audit helpers', () => {
|
|
173
|
+
it('detects channel from request shape', () => {
|
|
174
|
+
expect(detectChannel({ originalUrl: '/mcp', signedCookies: {} })).toBe('mcp')
|
|
175
|
+
expect(detectChannel({ signedCookies: { auth: 'token' } })).toBe('web_ui')
|
|
176
|
+
expect(detectChannel({ get: () => 'Bearer x' })).toBe('api')
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it('derives module id from task id', () => {
|
|
180
|
+
expect(moduleIdFromTaskId('@ossy/booking/tasks/create')).toBe('booking')
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it('hashes payloads deterministically', () => {
|
|
184
|
+
expect(hashPayload({ a: 1 })).toBe(hashPayload({ a: 1 }))
|
|
185
|
+
expect(hashPayload({ a: 1 })).not.toBe(hashPayload({ a: 2 }))
|
|
186
|
+
})
|
|
187
|
+
})
|
package/src/test/e2e.util.js
CHANGED
|
@@ -2,25 +2,22 @@ import { MongoClient } from 'mongodb'
|
|
|
2
2
|
|
|
3
3
|
const DB_URL = process.env.DB_URL ?? 'mongodb://localhost:27017/'
|
|
4
4
|
const DB_NAME = process.env.DB_NAME ?? 'ossy-local'
|
|
5
|
+
const USER_SCHEMA = '@ossy/users/schema/user'
|
|
6
|
+
const TOKEN_SCHEMA = '@ossy/tokens/schema/token'
|
|
7
|
+
const WORKSPACE_SCHEMA = '@ossy/workspaces/schema/workspace'
|
|
8
|
+
|
|
9
|
+
export { DB_URL, DB_NAME }
|
|
5
10
|
export const API_URL = process.env.OSSY_API_URL ?? process.env.API_URL ?? 'http://localhost:3001/api/v0'
|
|
6
11
|
export const ACTIONS_URL = API_URL.replace(/\/api\/v0\/?$/, '')
|
|
7
12
|
export const APP_URL = process.env.OSSY_APP_URL ?? process.env.BASE_URL ?? 'http://localhost:3002'
|
|
8
13
|
|
|
9
|
-
|
|
10
|
-
* Polls MongoDB until a Verification token appears for the given userId,
|
|
11
|
-
* or throws if it doesn't arrive within the timeout.
|
|
12
|
-
*
|
|
13
|
-
* Uses directConnection=true so the driver doesn't follow the replica-set
|
|
14
|
-
* member list (which uses Docker-internal hostnames like "mongodb:27017"
|
|
15
|
-
* even when connecting via localhost port-forwarding).
|
|
16
|
-
*/
|
|
17
|
-
async function getVerificationToken(db, userId, timeoutMs = 10000) {
|
|
14
|
+
async function getVerificationToken (db, userId, timeoutMs = 10000) {
|
|
18
15
|
const eventstore = db.collection('eventstore')
|
|
19
16
|
const deadline = Date.now() + timeoutMs
|
|
20
17
|
while (Date.now() < deadline) {
|
|
21
18
|
const event = await eventstore.findOne({
|
|
22
|
-
|
|
23
|
-
|
|
19
|
+
type: TOKEN_SCHEMA,
|
|
20
|
+
event: 'Created',
|
|
24
21
|
'payload.type': 'Verification',
|
|
25
22
|
createdBy: userId,
|
|
26
23
|
}, { sort: { _id: -1 } })
|
|
@@ -30,35 +27,27 @@ async function getVerificationToken(db, userId, timeoutMs = 10000) {
|
|
|
30
27
|
throw new Error(`Verification token for user ${userId} not found within ${timeoutMs}ms`)
|
|
31
28
|
}
|
|
32
29
|
|
|
33
|
-
|
|
34
|
-
* Polls MongoDB until a SignedUp event for the given email is found.
|
|
35
|
-
*/
|
|
36
|
-
async function getUserIdByEmail(db, email, timeoutMs = 10000) {
|
|
30
|
+
async function getUserIdByEmail (db, email, timeoutMs = 10000) {
|
|
37
31
|
const eventstore = db.collection('eventstore')
|
|
38
32
|
const deadline = Date.now() + timeoutMs
|
|
39
33
|
while (Date.now() < deadline) {
|
|
40
34
|
const event = await eventstore.findOne({
|
|
41
|
-
|
|
42
|
-
|
|
35
|
+
type: USER_SCHEMA,
|
|
36
|
+
event: 'Created',
|
|
43
37
|
'payload.email': email,
|
|
44
38
|
}, { sort: { _id: -1 } })
|
|
45
|
-
if (event?.
|
|
39
|
+
if (event?.resourceId) return event.resourceId
|
|
46
40
|
await new Promise(r => setTimeout(r, 200))
|
|
47
41
|
}
|
|
48
|
-
throw new Error(`
|
|
42
|
+
throw new Error(`Created user event for ${email} not found within ${timeoutMs}ms`)
|
|
49
43
|
}
|
|
50
44
|
|
|
51
|
-
|
|
52
|
-
* Polls MongoDB until the workspace aggregate for the given userId appears.
|
|
53
|
-
* Not fatal if it doesn't arrive — tests that rely on workspace state should
|
|
54
|
-
* handle the null return.
|
|
55
|
-
*/
|
|
56
|
-
async function waitForWorkspaceAggregate(db, userId, timeoutMs = 15000) {
|
|
45
|
+
async function waitForWorkspaceAggregate (db, userId, timeoutMs = 15000) {
|
|
57
46
|
const aggregates = db.collection('aggregates')
|
|
58
47
|
const deadline = Date.now() + timeoutMs
|
|
59
48
|
while (Date.now() < deadline) {
|
|
60
49
|
const workspace = await aggregates.findOne({
|
|
61
|
-
type:
|
|
50
|
+
type: WORKSPACE_SCHEMA,
|
|
62
51
|
'state.users': userId,
|
|
63
52
|
})
|
|
64
53
|
if (workspace) return workspace
|
|
@@ -67,14 +56,7 @@ async function waitForWorkspaceAggregate(db, userId, timeoutMs = 15000) {
|
|
|
67
56
|
return null
|
|
68
57
|
}
|
|
69
58
|
|
|
70
|
-
|
|
71
|
-
* Signs up a new user via the API and returns `{ email, userId, token }`.
|
|
72
|
-
*
|
|
73
|
-
* Waits for the workspace aggregate to be built so tests that rely on
|
|
74
|
-
* `useWorkspaces()` for the post-login redirect will find a workspace
|
|
75
|
-
* immediately. Uses a unique email per call to avoid cross-run conflicts.
|
|
76
|
-
*/
|
|
77
|
-
export async function signUpAndGetToken(firstName = 'Test', lastName = 'User') {
|
|
59
|
+
export async function signUpAndGetToken (firstName = 'Test', lastName = 'User') {
|
|
78
60
|
const email = `e2e-${Date.now()}@ossy.local`
|
|
79
61
|
const client = new MongoClient(DB_URL, { directConnection: true })
|
|
80
62
|
try {
|
|
@@ -84,7 +66,7 @@ export async function signUpAndGetToken(firstName = 'Test', lastName = 'User') {
|
|
|
84
66
|
const res = await fetch(`${ACTIONS_URL}/actions`, {
|
|
85
67
|
method: 'POST',
|
|
86
68
|
headers: { 'content-type': 'application/json' },
|
|
87
|
-
body: JSON.stringify({ action: 'authentication/sign-up', payload: { email, firstName, lastName } }),
|
|
69
|
+
body: JSON.stringify({ action: '@ossy/authentication/actions/sign-up', payload: { email, firstName, lastName } }),
|
|
88
70
|
})
|
|
89
71
|
if (!res.ok) throw new Error(`Sign-up failed with status ${res.status}`)
|
|
90
72
|
|
|
@@ -98,10 +80,7 @@ export async function signUpAndGetToken(firstName = 'Test', lastName = 'User') {
|
|
|
98
80
|
}
|
|
99
81
|
}
|
|
100
82
|
|
|
101
|
-
|
|
102
|
-
* Calls the verify-sign-in endpoint and returns the raw Set-Cookie header.
|
|
103
|
-
*/
|
|
104
|
-
export async function verifySignIn(token) {
|
|
83
|
+
export async function verifySignIn (token) {
|
|
105
84
|
const res = await fetch(`${API_URL}/users/verify-sign-in?token=${token}`)
|
|
106
85
|
if (!res.ok) throw new Error(`Verify sign-in failed with status ${res.status}`)
|
|
107
86
|
const cookie = res.headers.get('set-cookie')
|