@ossy/platform 1.39.2 → 3.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -17
- package/package.json +20 -11
- package/src/Definition.js +2 -1
- package/src/PlatformShell.jsx +10 -10
- package/src/actions/action.service.js +85 -7
- package/src/audit/action-invocation-service.js +143 -0
- package/src/audit/action-invocation.aggregate.js +57 -0
- package/src/audit/audit-helpers.js +61 -0
- package/src/audit/detect-channel.js +19 -0
- package/src/audit/list-task-runs.action.js +5 -0
- package/src/audit/list-task-runs.task.js +17 -0
- package/src/audit/task-run-list.aggregate.js +76 -0
- package/src/audit/task-run-service.js +208 -0
- package/src/audit/task-run.aggregate.js +58 -0
- package/src/auth/action-scopes.js +3 -0
- package/src/capability-schemas/action-capability.schema.js +1 -0
- package/src/capability-schemas/action-meta.schema.js +1 -0
- package/src/capability-schemas/component-capability.schema.js +1 -0
- package/src/capability-schemas/page-capability.schema.js +1 -0
- package/src/capability-schemas/page-meta.schema.js +1 -0
- package/src/capability-schemas/task-capability.schema.js +1 -0
- package/src/capability-schemas/task-meta.schema.js +1 -0
- package/src/capability-schemas/task-output.schema.js +1 -0
- package/src/capability-schemas/task-trigger-authoring.schema.js +1 -0
- package/src/capability-schemas/task-trigger.schema.js +1 -0
- package/src/directory.schema.js +7 -0
- package/src/entitlements/action-entitlement.js +69 -0
- package/src/file.schema.js +11 -0
- package/src/index.js +12 -3
- package/src/mcp/create-ossy-mcp-server.js +97 -0
- package/src/mcp/json-schema-to-zod.js +64 -0
- package/src/mcp/mount-ossy-mcp.js +72 -0
- package/src/mcp/mount-platform-mcp.js +101 -0
- package/src/mcp/upload-file-tool.js +62 -0
- package/src/metering/metering-service.js +91 -0
- package/src/{platform-config.resource.js → platform-config.schema.js} +1 -1
- package/src/proxy-internal.js +13 -16
- package/src/push/mount-push-sse.js +91 -0
- package/src/request-diagnostics.js +144 -0
- package/src/request-diagnostics.spec.js +40 -0
- package/src/resources/index.js +3 -2
- package/src/resources/schema.registry.js +26 -0
- package/src/resources/schema.service.js +54 -0
- package/src/resources/schema.validation.js +90 -0
- package/src/runtime.js +20 -6
- package/src/server.js +281 -62
- package/src/storage/filesystem-storage.client.js +109 -0
- package/src/storage/local-storage.client.js +2 -65
- package/src/storage/resource-read-url.js +36 -0
- package/src/storage/resource-read-url.spec.js +22 -0
- package/src/storage/s3-storage.client.js +102 -0
- package/src/storage/s3.client.js +27 -23
- package/src/storage/storage-keys.js +37 -0
- package/src/storage/storage-keys.spec.js +16 -0
- package/src/storage/storage.client.js +52 -8
- package/src/storage/storage.integration.js +40 -0
- package/src/tasks/change-stream.js +78 -11
- package/src/tasks/task-service.js +211 -34
- package/src/tasks/task-service.spec.js +187 -0
- package/src/test/e2e.util.js +18 -39
- package/src/test/flow-runner.js +476 -0
- package/src/test/test.util.js +30 -29
- package/src/user-app-settings.js +44 -0
- package/src/users.middleware.js +61 -17
- package/src/resources/resource-template.registry.js +0 -29
- package/src/resources/resource-template.validation.js +0 -232
|
@@ -1,11 +1,21 @@
|
|
|
1
1
|
import { matchesGlob } from './glob.js'
|
|
2
2
|
import { matchesCron } from './cron.js'
|
|
3
3
|
import { IntegrationService } from '../integration.service.js'
|
|
4
|
-
import { createLogger
|
|
4
|
+
import { createLogger } from '@ossy/observability'
|
|
5
|
+
import { isPrimaryTaskForAction, taskIdFromActionId } from '@ossy/schema'
|
|
6
|
+
import { TaskRunService } from '../audit/task-run-service.js'
|
|
5
7
|
|
|
6
8
|
const SCHEDULER_INTERVAL_MS = 60_000
|
|
7
9
|
const _serviceLog = createLogger('TaskService')
|
|
8
10
|
|
|
11
|
+
function buildTaskContext (baseContext, extras = {}) {
|
|
12
|
+
return {
|
|
13
|
+
...baseContext,
|
|
14
|
+
...extras,
|
|
15
|
+
payload: baseContext.payload ?? extras.payload,
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
9
19
|
export class TaskService {
|
|
10
20
|
|
|
11
21
|
static _stopped = false
|
|
@@ -80,13 +90,44 @@ export class TaskService {
|
|
|
80
90
|
* Synchronously invoke a task by id (HTTP / sdk.invoke path).
|
|
81
91
|
*
|
|
82
92
|
* @param {string} id
|
|
83
|
-
* @param {{ payload?: unknown, sdk?: unknown, log?: unknown, integrations?: unknown, req?: unknown }} context
|
|
93
|
+
* @param {{ payload?: unknown, sdk?: unknown, log?: unknown, integrations?: unknown, req?: unknown, actionInvocationId?: string }} context
|
|
84
94
|
* @returns {Promise<unknown>}
|
|
85
95
|
*/
|
|
86
96
|
static async invoke(id, context = {}) {
|
|
87
97
|
const task = TaskService._tasksById.get(id)
|
|
88
98
|
if (!task) throw new Error(`[TaskService] Task not found: "${id}"`)
|
|
89
|
-
|
|
99
|
+
|
|
100
|
+
const audit = context.audit !== false && task.metadata.audit !== false
|
|
101
|
+
|
|
102
|
+
return TaskRunService.execute({
|
|
103
|
+
taskId: id,
|
|
104
|
+
handler: task.handler,
|
|
105
|
+
context,
|
|
106
|
+
trigger: 'invoke',
|
|
107
|
+
triggeredBy: {
|
|
108
|
+
channel: context.req ? undefined : 'api',
|
|
109
|
+
actionInvocationId: context.actionInvocationId ?? null,
|
|
110
|
+
},
|
|
111
|
+
executionEnv: 'ossy_server',
|
|
112
|
+
audit,
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** @param {string} id */
|
|
117
|
+
static has (id) {
|
|
118
|
+
return TaskService._tasksById.has(id)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* @param {string} actionId
|
|
123
|
+
* @returns {boolean}
|
|
124
|
+
*/
|
|
125
|
+
static hasTaskForAction (actionId) {
|
|
126
|
+
try {
|
|
127
|
+
return TaskService.has(taskIdFromActionId(actionId))
|
|
128
|
+
} catch {
|
|
129
|
+
return false
|
|
130
|
+
}
|
|
90
131
|
}
|
|
91
132
|
|
|
92
133
|
/**
|
|
@@ -101,23 +142,107 @@ export class TaskService {
|
|
|
101
142
|
|
|
102
143
|
for (const { metadata, handler } of TaskService._tasks) {
|
|
103
144
|
const triggers = metadata.triggers ?? []
|
|
104
|
-
const matched = triggers.some(trigger => TaskService.
|
|
145
|
+
const matched = triggers.some(trigger => TaskService._matchesEventTrigger(trigger, event))
|
|
105
146
|
|
|
106
147
|
if (!matched) continue
|
|
107
148
|
|
|
108
149
|
_serviceLog.info(`Dispatching task "${metadata.id}"`)
|
|
109
150
|
|
|
151
|
+
const taskContext = buildTaskContext(
|
|
152
|
+
{ sdk: effectiveSdk, integrations: IntegrationService, log: createLogger(metadata.id) },
|
|
153
|
+
{
|
|
154
|
+
event,
|
|
155
|
+
payload: { workspaceId: event.payload?.belongsTo },
|
|
156
|
+
},
|
|
157
|
+
)
|
|
158
|
+
|
|
110
159
|
const _taskId = metadata.id
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
160
|
+
TaskRunService.executeAsync({
|
|
161
|
+
taskId: _taskId,
|
|
162
|
+
handler,
|
|
163
|
+
context: taskContext,
|
|
164
|
+
trigger: 'on_event',
|
|
165
|
+
triggeredBy: {
|
|
166
|
+
eventId: event.id,
|
|
167
|
+
resourceId: event.resourceId,
|
|
168
|
+
type: event.type,
|
|
169
|
+
event: event.event,
|
|
170
|
+
},
|
|
171
|
+
executionEnv: 'ossy_server',
|
|
172
|
+
audit: metadata.audit !== false,
|
|
173
|
+
})
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Dispatches follow-up tasks registered with `on_action` triggers after an action completes.
|
|
179
|
+
*
|
|
180
|
+
* @param {{
|
|
181
|
+
* actionId: string,
|
|
182
|
+
* actionInvocationId?: string,
|
|
183
|
+
* payload?: unknown,
|
|
184
|
+
* result?: unknown,
|
|
185
|
+
* success: boolean,
|
|
186
|
+
* error?: Error,
|
|
187
|
+
* req?: import('express').Request,
|
|
188
|
+
* sdk?: unknown,
|
|
189
|
+
* }} actionContext
|
|
190
|
+
*/
|
|
191
|
+
static dispatchOnAction (actionContext) {
|
|
192
|
+
const {
|
|
193
|
+
actionId,
|
|
194
|
+
actionInvocationId,
|
|
195
|
+
payload,
|
|
196
|
+
result,
|
|
197
|
+
success,
|
|
198
|
+
error,
|
|
199
|
+
req,
|
|
200
|
+
sdk,
|
|
201
|
+
} = actionContext
|
|
202
|
+
|
|
203
|
+
for (const { metadata, handler } of TaskService._tasks) {
|
|
204
|
+
if (isPrimaryTaskForAction(actionId, metadata.id)) continue
|
|
205
|
+
|
|
206
|
+
const triggers = metadata.triggers ?? []
|
|
207
|
+
const matched = triggers.some(trigger => TaskService._matchesActionTrigger(trigger, actionId))
|
|
208
|
+
|
|
209
|
+
if (!matched) continue
|
|
210
|
+
|
|
211
|
+
_serviceLog.info(`Dispatching on_action task "${metadata.id}" for action "${actionId}"`)
|
|
212
|
+
|
|
213
|
+
const taskContext = buildTaskContext(
|
|
214
|
+
{
|
|
215
|
+
sdk: sdk ?? TaskService._sdk,
|
|
216
|
+
integrations: IntegrationService,
|
|
217
|
+
log: createLogger(metadata.id),
|
|
218
|
+
req,
|
|
219
|
+
actionInvocationId,
|
|
220
|
+
payload,
|
|
221
|
+
},
|
|
222
|
+
{
|
|
223
|
+
action: {
|
|
224
|
+
id: actionId,
|
|
225
|
+
success,
|
|
226
|
+
result,
|
|
227
|
+
error: error ? { message: error.message } : undefined,
|
|
228
|
+
},
|
|
229
|
+
},
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
const _taskId = metadata.id
|
|
233
|
+
TaskRunService.executeAsync({
|
|
234
|
+
taskId: _taskId,
|
|
235
|
+
handler,
|
|
236
|
+
context: taskContext,
|
|
237
|
+
trigger: 'on_action',
|
|
238
|
+
triggeredBy: {
|
|
239
|
+
actionId,
|
|
240
|
+
actionInvocationId: actionInvocationId ?? null,
|
|
241
|
+
success,
|
|
242
|
+
},
|
|
243
|
+
executionEnv: 'ossy_server',
|
|
244
|
+
audit: metadata.audit !== false,
|
|
245
|
+
})
|
|
121
246
|
}
|
|
122
247
|
}
|
|
123
248
|
|
|
@@ -143,17 +268,23 @@ export class TaskService {
|
|
|
143
268
|
|
|
144
269
|
_serviceLog.info(`Schedule fired for task "${metadata.id}"`)
|
|
145
270
|
|
|
271
|
+
const taskContext = {
|
|
272
|
+
event: { type: 'scheduled', taskId: metadata.id },
|
|
273
|
+
sdk: TaskService._sdk,
|
|
274
|
+
integrations: IntegrationService,
|
|
275
|
+
log: createLogger(metadata.id),
|
|
276
|
+
}
|
|
277
|
+
|
|
146
278
|
const _taskId = metadata.id
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
})
|
|
279
|
+
TaskRunService.executeAsync({
|
|
280
|
+
taskId: _taskId,
|
|
281
|
+
handler,
|
|
282
|
+
context: taskContext,
|
|
283
|
+
trigger: 'on_schedule',
|
|
284
|
+
triggeredBy: { schedule: metadata.schedule },
|
|
285
|
+
executionEnv: 'ossy_server',
|
|
286
|
+
audit: metadata.audit !== false,
|
|
287
|
+
})
|
|
157
288
|
}
|
|
158
289
|
}, SCHEDULER_INTERVAL_MS)
|
|
159
290
|
}
|
|
@@ -170,19 +301,65 @@ export class TaskService {
|
|
|
170
301
|
}
|
|
171
302
|
|
|
172
303
|
/**
|
|
173
|
-
* Returns true when
|
|
304
|
+
* Returns true when an `on_action` trigger matches the given action id.
|
|
174
305
|
*
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
* trigger.event — exact match against event.type
|
|
178
|
-
* trigger.resource.type — glob match (supports *) against event.payload.type
|
|
179
|
-
* trigger.location.startsWith — prefix match against event.payload.location
|
|
306
|
+
* @param {object} trigger
|
|
307
|
+
* @param {string} actionId
|
|
180
308
|
*/
|
|
181
|
-
static
|
|
182
|
-
|
|
183
|
-
if (
|
|
184
|
-
|
|
309
|
+
static _matchesActionTrigger (trigger, actionId) {
|
|
310
|
+
const kind = trigger.trigger ?? trigger.on
|
|
311
|
+
if (kind !== 'on_action') return false
|
|
312
|
+
|
|
313
|
+
const pattern = trigger.action
|
|
314
|
+
if (!pattern) return false
|
|
315
|
+
|
|
316
|
+
return matchesGlob(pattern, actionId)
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Returns true when an event trigger descriptor matches the given eventstore document.
|
|
321
|
+
*
|
|
322
|
+
* ADR 0008: `trigger.type` = schemaId, `trigger.event` = lifecycle/custom name.
|
|
323
|
+
* Skips `on_action` triggers (handled by {@link TaskService.dispatchOnAction}).
|
|
324
|
+
*/
|
|
325
|
+
static _matchesEventTrigger (trigger, event) {
|
|
326
|
+
const kind = trigger.trigger ?? trigger.on
|
|
327
|
+
if (kind === 'on_action') return false
|
|
328
|
+
|
|
329
|
+
if (trigger.excludeTypes?.includes(event.type)) return false
|
|
330
|
+
|
|
331
|
+
const hasPositiveFilter = Boolean(
|
|
332
|
+
trigger.type ||
|
|
333
|
+
trigger.event ||
|
|
334
|
+
trigger.match ||
|
|
335
|
+
trigger.resource ||
|
|
336
|
+
trigger.location,
|
|
337
|
+
)
|
|
338
|
+
if (!hasPositiveFilter && trigger.excludeTypes?.length) return true
|
|
339
|
+
|
|
340
|
+
if (trigger.type && trigger.type !== event.type) return false
|
|
341
|
+
if (trigger.event && trigger.event !== event.event) return false
|
|
342
|
+
|
|
343
|
+
if (trigger.match) {
|
|
344
|
+
for (const [path, pattern] of Object.entries(trigger.match)) {
|
|
345
|
+
const fullPath = path.startsWith('payload.') ? path : `payload.${path}`
|
|
346
|
+
const parts = fullPath.split('.')
|
|
347
|
+
let cur = event
|
|
348
|
+
for (const part of parts) {
|
|
349
|
+
if (cur == null || typeof cur !== 'object') { cur = undefined; break }
|
|
350
|
+
cur = cur[part]
|
|
351
|
+
}
|
|
352
|
+
if (!matchesGlob(pattern, cur)) return false
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (trigger.resource?.type) {
|
|
357
|
+
const mime = event.payload?.content?.ContentType ?? event.type
|
|
358
|
+
if (!matchesGlob(trigger.resource.type, mime)) return false
|
|
359
|
+
}
|
|
360
|
+
|
|
185
361
|
if (trigger.location?.startsWith && !event.payload?.location?.startsWith?.(trigger.location.startsWith)) return false
|
|
362
|
+
|
|
186
363
|
return true
|
|
187
364
|
}
|
|
188
365
|
|
|
@@ -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')
|