@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.
Files changed (65) hide show
  1. package/README.md +21 -17
  2. package/package.json +20 -11
  3. package/src/Definition.js +1 -0
  4. package/src/PlatformShell.jsx +10 -10
  5. package/src/actions/action.service.js +79 -6
  6. package/src/audit/action-invocation-service.js +144 -0
  7. package/src/audit/action-invocation.aggregate.js +59 -0
  8. package/src/audit/audit-helpers.js +61 -0
  9. package/src/audit/detect-channel.js +19 -0
  10. package/src/audit/list-task-runs.action.js +5 -0
  11. package/src/audit/list-task-runs.task.js +17 -0
  12. package/src/audit/task-run-list.aggregate.js +78 -0
  13. package/src/audit/task-run-service.js +193 -0
  14. package/src/audit/task-run.aggregate.js +60 -0
  15. package/src/auth/action-scopes.js +3 -0
  16. package/src/capability-schemas/action-capability.schema.js +1 -0
  17. package/src/capability-schemas/action-meta.schema.js +1 -0
  18. package/src/capability-schemas/component-capability.schema.js +1 -0
  19. package/src/capability-schemas/page-capability.schema.js +1 -0
  20. package/src/capability-schemas/page-meta.schema.js +1 -0
  21. package/src/capability-schemas/task-capability.schema.js +1 -0
  22. package/src/capability-schemas/task-meta.schema.js +1 -0
  23. package/src/capability-schemas/task-output.schema.js +1 -0
  24. package/src/capability-schemas/task-trigger-authoring.schema.js +1 -0
  25. package/src/capability-schemas/task-trigger.schema.js +1 -0
  26. package/src/directory.schema.js +9 -0
  27. package/src/entitlements/action-entitlement.js +69 -0
  28. package/src/file.schema.js +13 -0
  29. package/src/index.js +13 -3
  30. package/src/mcp/create-ossy-mcp-server.js +97 -0
  31. package/src/mcp/json-schema-to-zod.js +64 -0
  32. package/src/mcp/mount-ossy-mcp.js +72 -0
  33. package/src/mcp/mount-platform-mcp.js +101 -0
  34. package/src/mcp/upload-file-tool.js +62 -0
  35. package/src/metering/metering-service.js +92 -0
  36. package/src/{platform-config.resource.js → platform-config.schema.js} +1 -1
  37. package/src/proxy-internal.js +13 -16
  38. package/src/push/mount-push-sse.js +43 -0
  39. package/src/resources/index.js +3 -2
  40. package/src/resources/schema.registry.js +26 -0
  41. package/src/resources/schema.service.js +54 -0
  42. package/src/resources/schema.validation.js +90 -0
  43. package/src/runtime.js +4 -1
  44. package/src/schema-ids.js +9 -0
  45. package/src/server.js +121 -20
  46. package/src/storage/filesystem-storage.client.js +109 -0
  47. package/src/storage/local-storage.client.js +2 -65
  48. package/src/storage/resource-read-url.js +36 -0
  49. package/src/storage/resource-read-url.spec.js +22 -0
  50. package/src/storage/s3-storage.client.js +102 -0
  51. package/src/storage/s3.client.js +27 -23
  52. package/src/storage/storage-keys.js +37 -0
  53. package/src/storage/storage-keys.spec.js +16 -0
  54. package/src/storage/storage.client.js +52 -8
  55. package/src/storage/storage.integration.js +40 -0
  56. package/src/tasks/change-stream.js +9 -2
  57. package/src/tasks/task-service.js +211 -34
  58. package/src/tasks/task-service.spec.js +187 -0
  59. package/src/test/e2e.util.js +18 -39
  60. package/src/test/flow-runner.js +476 -0
  61. package/src/test/test.util.js +30 -29
  62. package/src/user-app-settings.js +44 -0
  63. package/src/users.middleware.js +14 -2
  64. package/src/resources/resource-template.registry.js +0 -29
  65. 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
+ })
@@ -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
- aggregateType: 'Token',
23
- type: 'Created',
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
- aggregateType: 'User',
42
- type: 'SignedUp',
35
+ type: USER_SCHEMA,
36
+ event: 'Created',
43
37
  'payload.email': email,
44
38
  }, { sort: { _id: -1 } })
45
- if (event?.aggregateId) return event.aggregateId
39
+ if (event?.resourceId) return event.resourceId
46
40
  await new Promise(r => setTimeout(r, 200))
47
41
  }
48
- throw new Error(`SignedUp event for ${email} not found within ${timeoutMs}ms`)
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: 'Workspace',
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')