@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
@@ -2,8 +2,9 @@ import { S3, PutObjectCommand, GetObjectCommand, HeadObjectCommand } from '@aws-
2
2
  import { S3RequestPresigner } from '@aws-sdk/s3-request-presigner'
3
3
  import { createRequest } from '@aws-sdk/util-create-request'
4
4
  import { formatUrl } from '@aws-sdk/util-format-url'
5
- import { ConfigService } from '../config.service.js'
6
5
  import { createLogger } from '@ossy/observability'
6
+ import { createResourceReadUrl } from './resource-read-url.js'
7
+ import { ConfigService } from '../config.service.js'
7
8
 
8
9
  const log = createLogger('storage')
9
10
 
@@ -30,35 +31,18 @@ function normalizeKey(keyOrShape) {
30
31
  export class S3Client {
31
32
 
32
33
  /**
33
- * Public CDN URL (CloudFront) for objects under `media/` use only when `access === 'public'`.
34
+ * App read URL same shape as local filesystem (`/r/{resourceId}`).
34
35
  */
35
36
  static createDownloadUrl(keyOrShape) {
36
37
  const Key = normalizeKey(keyOrShape)
37
- const pathAfterMedia = Key.includes('media/') ? Key.split('media/')[1] : Key
38
- const base = ConfigService.MediaCdnDomainName || ''
39
- return `${base.replace(/\/$/, '')}/${pathAfterMedia}`
38
+ return createResourceReadUrl(Key)
40
39
  }
41
40
 
42
41
  /**
43
- * Short-lived presigned GET for private bucket reads (restricted resources, workspace members).
42
+ * Same as createDownloadUrl auth is enforced by `/r/` route handlers.
44
43
  */
45
- static createPresignedDownloadUrl(keyOrShape, { expiresIn = 15 * 60 } = {}) {
46
- const Key = normalizeKey(keyOrShape)
47
- const Bucket = ConfigService.MediaRepository
48
- if (!Bucket) {
49
- return Promise.reject(new Error('[S3Client] MEDIA_REPOSITORY is not configured'))
50
- }
51
-
52
- const s3Client = createS3Client()
53
- const signedRequest = new S3RequestPresigner(s3Client.config)
54
-
55
- return createRequest(s3Client, new GetObjectCommand({ Bucket, Key }))
56
- .then(request => signedRequest.presign(request, { expiresIn }))
57
- .then(formatUrl)
58
- .catch(error => {
59
- log.error('S3Client.createPresignedDownloadUrl error', undefined, error)
60
- return Promise.reject(error)
61
- })
44
+ static createPresignedDownloadUrl(keyOrShape) {
45
+ return Promise.resolve(S3Client.createDownloadUrl(keyOrShape))
62
46
  }
63
47
 
64
48
  static createUploadUrl({ ContentType, ContentLength, Key, Bucket, expiresIn = 60 * 60 * 24 }) {
@@ -97,4 +81,24 @@ export class S3Client {
97
81
  return s3Client.send(new HeadObjectCommand({ Bucket: bucket, Key }))
98
82
  }
99
83
 
84
+ static async load(keyOrShape) {
85
+ const Key = normalizeKey(keyOrShape)
86
+ const Bucket = ConfigService.MediaRepository
87
+ if (!Bucket) {
88
+ return { buffer: null, exists: false }
89
+ }
90
+ const s3Client = createS3Client()
91
+ try {
92
+ const response = await s3Client.send(new GetObjectCommand({ Bucket, Key }))
93
+ const bytes = await response.Body?.transformToByteArray?.()
94
+ if (!bytes) return { buffer: null, exists: false }
95
+ return { buffer: Buffer.from(bytes), exists: true }
96
+ } catch (err) {
97
+ if (err?.$metadata?.httpStatusCode === 404 || err?.name === 'NoSuchKey') {
98
+ return { buffer: null, exists: false }
99
+ }
100
+ throw err
101
+ }
102
+ }
103
+
100
104
  }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Flat blob keys — folder hierarchy lives in Mongo resource metadata (`location`), not in storage.
3
+ *
4
+ * Original binary: `{resourceId}`
5
+ * Derivatives (thumbnails, etc.): `{resourceId}:{variant}`
6
+ */
7
+
8
+ const KEY_PATTERN = /^[A-Za-z0-9_-]+(:[A-Za-z0-9_-]+)?$/
9
+
10
+ export function originalObjectKey (resourceId) {
11
+ assertResourceId(resourceId)
12
+ return resourceId
13
+ }
14
+
15
+ export function derivativeObjectKey (resourceId, variant) {
16
+ assertResourceId(resourceId)
17
+ if (!variant || typeof variant !== 'string') {
18
+ throw new Error('[storage-keys] variant is required')
19
+ }
20
+ if (!/^[A-Za-z0-9_-]+$/.test(variant)) {
21
+ throw new Error('[storage-keys] invalid variant')
22
+ }
23
+ return `${resourceId}:${variant}`
24
+ }
25
+
26
+ export function assertStorageKey (key) {
27
+ if (!key || typeof key !== 'string' || !KEY_PATTERN.test(key)) {
28
+ throw new Error(`[storage-keys] invalid storage key: ${key}`)
29
+ }
30
+ return key
31
+ }
32
+
33
+ function assertResourceId (resourceId) {
34
+ if (!resourceId || typeof resourceId !== 'string' || !/^[A-Za-z0-9_-]+$/.test(resourceId)) {
35
+ throw new Error(`[storage-keys] invalid resourceId: ${resourceId}`)
36
+ }
37
+ }
@@ -0,0 +1,16 @@
1
+ import { describe, expect, it } from '@jest/globals'
2
+ import { originalObjectKey, derivativeObjectKey, assertStorageKey } from './storage-keys.js'
3
+
4
+ describe('storage-keys', () => {
5
+ it('originalObjectKey returns flat resourceId', () => {
6
+ expect(originalObjectKey('abc123XYZ')).toBe('abc123XYZ')
7
+ })
8
+
9
+ it('derivativeObjectKey uses resourceId:variant', () => {
10
+ expect(derivativeObjectKey('abc123', 'galleryMedium')).toBe('abc123:galleryMedium')
11
+ })
12
+
13
+ it('rejects path-like keys', () => {
14
+ expect(() => assertStorageKey('media/ws/file')).toThrow()
15
+ })
16
+ })
@@ -1,12 +1,56 @@
1
- import { S3Client } from './s3.client.js'
2
- import { LocalStorageClient } from './local-storage.client.js'
1
+ import { IntegrationService } from '../integration.service.js'
3
2
  import { ConfigService } from '../config.service.js'
3
+ import { createFilesystemStorageClient } from './filesystem-storage.client.js'
4
+ import { createS3StorageClient } from './s3-storage.client.js'
5
+
6
+ /** @type {import('./filesystem-storage.client.js').createFilesystemStorageClient extends (...args: any) => infer R ? R : never | null} */
7
+ let fallbackClient = null
8
+
9
+ function getFallbackClient () {
10
+ if (!fallbackClient) {
11
+ if (ConfigService.MediaRepository && ConfigService.awsAccessKeyId && ConfigService.awsSecretAccessKey) {
12
+ fallbackClient = createS3StorageClient({
13
+ bucket: ConfigService.MediaRepository,
14
+ cdnDomainName: ConfigService.MediaCdnDomainName,
15
+ accessKeyId: ConfigService.awsAccessKeyId,
16
+ secretAccessKey: ConfigService.awsSecretAccessKey,
17
+ })
18
+ } else {
19
+ fallbackClient = createFilesystemStorageClient()
20
+ }
21
+ }
22
+ return fallbackClient
23
+ }
24
+
25
+ function getStorageClient () {
26
+ return IntegrationService.get('storage') ?? getFallbackClient()
27
+ }
28
+
29
+ function bindMethod (name) {
30
+ return (...args) => {
31
+ const client = getStorageClient()
32
+ const fn = client[name]
33
+ if (typeof fn !== 'function') {
34
+ throw new Error(`[StorageClient] storage backend missing method "${name}"`)
35
+ }
36
+ return fn.apply(client, args)
37
+ }
38
+ }
4
39
 
5
40
  /**
6
- * Active storage backend.
7
- *
8
- * - When `MEDIA_REPOSITORY` is set (production): delegates to S3.
9
- * - When `MEDIA_REPOSITORY` is unset (local dev): delegates to LocalStorageClient,
10
- * which stores files in /tmp/ossy-local-media/ and serves them via GET /local-storage.
41
+ * Active primary storage backend (filesystem or S3 via `storage` integration).
11
42
  */
12
- export const StorageClient = ConfigService.MediaRepository ? S3Client : LocalStorageClient
43
+ export const StorageClient = {
44
+ get backend () {
45
+ return getStorageClient().backend
46
+ },
47
+ createUploadUrl: bindMethod('createUploadUrl'),
48
+ createPresignedDownloadUrl: bindMethod('createPresignedDownloadUrl'),
49
+ createDownloadUrl: bindMethod('createDownloadUrl'),
50
+ load: bindMethod('load'),
51
+ headObject: bindMethod('headObject'),
52
+ save: bindMethod('save'),
53
+ }
54
+
55
+ export { LocalStorageClient } from './filesystem-storage.client.js'
56
+ export { S3Client } from './s3.client.js'
@@ -0,0 +1,40 @@
1
+ import { createLogger } from '@ossy/observability'
2
+ import { createFilesystemStorageClient } from './filesystem-storage.client.js'
3
+ import { createS3StorageClient } from './s3-storage.client.js'
4
+
5
+ const log = createLogger('storage')
6
+
7
+ export const id = 'storage'
8
+
9
+ /** Always connect — filesystem when S3 is not configured. */
10
+ export const credentials = []
11
+
12
+ /**
13
+ * Primary blob storage: S3 when MEDIA_REPOSITORY is set, otherwise local filesystem.
14
+ *
15
+ * Logical keys are flat resource ids (see storage-keys.js). Mongo holds folder paths.
16
+ *
17
+ * @param {{ env: NodeJS.ProcessEnv }} opts
18
+ */
19
+ export async function connect ({ env }) {
20
+ const bucket = env.MEDIA_REPOSITORY
21
+
22
+ if (bucket) {
23
+ const missing = ['AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY'].filter((key) => !(key in env))
24
+ if (missing.length) {
25
+ log.warn(`[storage] MEDIA_REPOSITORY set but missing ${missing.join(', ')} — using filesystem fallback`)
26
+ } else {
27
+ log.info('[storage] Using S3 backend')
28
+ return createS3StorageClient({
29
+ bucket,
30
+ cdnDomainName: env.MEDIA_CDN_DOMAIN_NAME,
31
+ accessKeyId: env.AWS_ACCESS_KEY_ID,
32
+ secretAccessKey: env.AWS_SECRET_ACCESS_KEY,
33
+ })
34
+ }
35
+ }
36
+
37
+ const client = createFilesystemStorageClient({ env })
38
+ log.info(`[storage] Using filesystem backend at ${client.root}`)
39
+ return client
40
+ }
@@ -1,6 +1,6 @@
1
1
  import { TaskService } from './task-service.js'
2
2
  import { createLogger } from '@ossy/observability'
3
- import { resolveMongoUrl } from '@ossy/event-store'
3
+ import { ProjectionRebuild, PushInvalidation, resolveMongoUrl } from '@ossy/event-store'
4
4
 
5
5
  const log = createLogger('platform')
6
6
 
@@ -121,7 +121,14 @@ export class ChangeStream {
121
121
 
122
122
  changeStream.on('change', (change) => {
123
123
  log.info('[ChangeStream] Change detected')
124
- TaskService.dispatch(change.fullDocument)
124
+ const event = change.fullDocument
125
+ if (event) {
126
+ ProjectionRebuild.dispatch(event).catch((error) => {
127
+ log.error('[ChangeStream] Projection rebuild failed', undefined, error)
128
+ })
129
+ PushInvalidation.publish(event)
130
+ }
131
+ TaskService.dispatch(event)
125
132
  })
126
133
 
127
134
  changeStream.on('close', () => {
@@ -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, metrics } from '@ossy/observability'
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
- return task.handler(context)
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._matchesTrigger(trigger, event))
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
- const _start = Date.now()
112
- Promise.resolve()
113
- .then(() => handler({ event, sdk: effectiveSdk, integrations: IntegrationService, log: createLogger(metadata.id) }))
114
- .catch(error =>
115
- _serviceLog.error(`Task "${metadata.id}" failed`, undefined, error),
116
- )
117
- .finally(() => {
118
- try { metrics.timing('task.duration', Date.now() - _start, { task: _taskId }) } catch {}
119
- try { metrics.increment('task.run', { task: _taskId }) } catch {}
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
- const _start = Date.now()
148
- Promise.resolve()
149
- .then(() => handler({ event: { type: 'scheduled', taskId: metadata.id }, sdk: TaskService._sdk, integrations: IntegrationService, log: createLogger(metadata.id) }))
150
- .catch(error =>
151
- _serviceLog.error(`Scheduled task "${metadata.id}" failed`, undefined, error),
152
- )
153
- .finally(() => {
154
- try { metrics.timing('task.duration', Date.now() - _start, { task: _taskId }) } catch {}
155
- try { metrics.increment('task.run', { task: _taskId }) } catch {}
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 a trigger descriptor matches the given eventstore document.
304
+ * Returns true when an `on_action` trigger matches the given action id.
174
305
  *
175
- * Matching rules:
176
- * trigger.aggregateType — exact match against event.aggregateType
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 _matchesTrigger(trigger, event) {
182
- if (trigger.aggregateType && trigger.aggregateType !== event.aggregateType) return false
183
- if (trigger.event && trigger.event !== event.type) return false
184
- if (trigger.resource?.type && !matchesGlob(trigger.resource.type, event.payload?.type)) return false
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