@shipfox/api-triggers 10.2.0 → 12.0.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/.turbo/turbo-build.log +4 -4
- package/CHANGELOG.md +67 -0
- package/README.md +12 -12
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js +3 -3
- package/dist/core/config.js.map +1 -1
- package/dist/core/dispatch-integration-event.d.ts.map +1 -1
- package/dist/core/dispatch-integration-event.js +2 -0
- package/dist/core/dispatch-integration-event.js.map +1 -1
- package/dist/core/route-event-to-job-listeners.d.ts +1 -0
- package/dist/core/route-event-to-job-listeners.d.ts.map +1 -1
- package/dist/core/route-event-to-job-listeners.js +21 -4
- package/dist/core/route-event-to-job-listeners.js.map +1 -1
- package/dist/core/workflows-client.d.ts +10 -1
- package/dist/core/workflows-client.d.ts.map +1 -1
- package/dist/presentation/routes/fire-manual.d.ts.map +1 -1
- package/dist/presentation/routes/fire-manual.js +11 -3
- package/dist/presentation/routes/fire-manual.js.map +1 -1
- package/dist/presentation/routes/get-trigger-event.d.ts.map +1 -1
- package/dist/presentation/routes/get-trigger-event.js +11 -4
- package/dist/presentation/routes/get-trigger-event.js.map +1 -1
- package/dist/presentation/routes/list-trigger-event-facets.d.ts.map +1 -1
- package/dist/presentation/routes/list-trigger-event-facets.js +6 -8
- package/dist/presentation/routes/list-trigger-event-facets.js.map +1 -1
- package/dist/presentation/routes/list-trigger-events.d.ts.map +1 -1
- package/dist/presentation/routes/list-trigger-events.js +5 -7
- package/dist/presentation/routes/list-trigger-events.js.map +1 -1
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/package.json +13 -13
- package/src/core/config.test.ts +23 -0
- package/src/core/config.ts +3 -2
- package/src/core/dispatch-integration-event.history-failure.test.ts +5 -0
- package/src/core/dispatch-integration-event.test.ts +8 -1
- package/src/core/dispatch-integration-event.ts +2 -0
- package/src/core/route-event-to-job-listeners.test.ts +99 -0
- package/src/core/route-event-to-job-listeners.ts +29 -2
- package/src/core/workflows-client.test.ts +8 -0
- package/src/presentation/routes/fire-manual.test.ts +22 -2
- package/src/presentation/routes/fire-manual.ts +11 -3
- package/src/presentation/routes/get-trigger-event.test.ts +16 -2
- package/src/presentation/routes/get-trigger-event.ts +9 -4
- package/src/presentation/routes/list-trigger-event-facets.test.ts +18 -2
- package/src/presentation/routes/list-trigger-event-facets.ts +3 -6
- package/src/presentation/routes/list-trigger-events.test.ts +18 -2
- package/src/presentation/routes/list-trigger-events.ts +2 -5
- package/tsconfig.build.tsbuildinfo +1 -1
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shipfox/api-triggers",
|
|
3
3
|
"license": "MIT",
|
|
4
|
-
"version": "
|
|
4
|
+
"version": "12.0.0",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "git+https://github.com/ShipfoxHQ/shipfox.git",
|
|
@@ -22,23 +22,23 @@
|
|
|
22
22
|
"cron-parser": "^5.6.1",
|
|
23
23
|
"drizzle-orm": "^0.45.2",
|
|
24
24
|
"zod": "^4.4.3",
|
|
25
|
-
"@shipfox/api-
|
|
26
|
-
"@shipfox/api-
|
|
27
|
-
"@shipfox/api-integration-core-dto": "
|
|
28
|
-
"@shipfox/api-workflows-dto": "10.0.0",
|
|
25
|
+
"@shipfox/api-auth-context": "12.0.0",
|
|
26
|
+
"@shipfox/api-workflows-dto": "12.0.0",
|
|
27
|
+
"@shipfox/api-integration-core-dto": "12.0.0",
|
|
29
28
|
"@shipfox/api-triggers-dto": "9.0.2",
|
|
30
29
|
"@shipfox/config": "1.2.4",
|
|
31
|
-
"@shipfox/
|
|
32
|
-
"@shipfox/inter-module": "0.2.
|
|
30
|
+
"@shipfox/api-definitions-dto": "12.0.0",
|
|
31
|
+
"@shipfox/inter-module": "0.2.3",
|
|
32
|
+
"@shipfox/node-drizzle": "0.3.5",
|
|
33
|
+
"@shipfox/expression": "2.0.0",
|
|
33
34
|
"@shipfox/node-error-monitoring": "0.3.0",
|
|
34
|
-
"@shipfox/node-
|
|
35
|
-
"@shipfox/node-
|
|
36
|
-
"@shipfox/node-module": "1.0.4",
|
|
37
|
-
"@shipfox/node-postgres": "0.4.4",
|
|
35
|
+
"@shipfox/node-module": "1.0.5",
|
|
36
|
+
"@shipfox/node-fastify": "0.4.1",
|
|
38
37
|
"@shipfox/node-opentelemetry": "0.6.3",
|
|
39
38
|
"@shipfox/node-outbox": "0.2.6",
|
|
40
|
-
"@shipfox/
|
|
41
|
-
"@shipfox/node-
|
|
39
|
+
"@shipfox/node-temporal": "0.4.4",
|
|
40
|
+
"@shipfox/node-postgres": "0.5.0",
|
|
41
|
+
"@shipfox/workflow-document": "3.0.0"
|
|
42
42
|
},
|
|
43
43
|
"imports": {
|
|
44
44
|
"#*": "./dist/*"
|
package/src/core/config.test.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import * as expression from '@shipfox/expression';
|
|
1
2
|
import {evaluateStoredFilter, evaluateTriggerFilter, readConfigInputs} from './config.js';
|
|
2
3
|
import type {TriggerSubscription} from './entities/subscription.js';
|
|
3
4
|
|
|
@@ -89,6 +90,28 @@ describe('evaluateTriggerFilter', () => {
|
|
|
89
90
|
expect(misses).toEqual({kind: 'filtered'});
|
|
90
91
|
});
|
|
91
92
|
|
|
93
|
+
test('passes trigger filters through the field-specific context projection', () => {
|
|
94
|
+
const projectContext = vi.spyOn(expression, 'projectWorkflowPredicateContext');
|
|
95
|
+
const payload = {ref: 'refs/heads/main'};
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
const result = evaluateTriggerFilter({
|
|
99
|
+
subscription: subscriptionWithConfig({filter: 'event.ref == "refs/heads/main"'}),
|
|
100
|
+
source: 'github',
|
|
101
|
+
event: 'push',
|
|
102
|
+
payload,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
expect(result).toEqual({kind: 'matched'});
|
|
106
|
+
expect(projectContext).toHaveBeenCalledWith('trigger.filter', {
|
|
107
|
+
event: payload,
|
|
108
|
+
trigger: {source: 'github', event: 'push'},
|
|
109
|
+
});
|
|
110
|
+
} finally {
|
|
111
|
+
projectContext.mockRestore();
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
92
115
|
test('returns filter-error when the stored filter cannot be parsed', () => {
|
|
93
116
|
const subscription = subscriptionWithConfig({filter: 'event.ref =='});
|
|
94
117
|
|
package/src/core/config.ts
CHANGED
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
createWorkflowExpression,
|
|
3
3
|
evaluateWorkflowPredicateFailClosed,
|
|
4
4
|
InvalidWorkflowExpressionError,
|
|
5
|
+
projectWorkflowPredicateContext,
|
|
5
6
|
type WorkflowExpression,
|
|
6
7
|
} from '@shipfox/expression';
|
|
7
8
|
import type {TriggerSubscription} from './entities/subscription.js';
|
|
@@ -70,10 +71,10 @@ export function evaluateTriggerFilter(
|
|
|
70
71
|
): TriggerFilterEvaluation {
|
|
71
72
|
return evaluateStoredFilter({
|
|
72
73
|
value: params.subscription.config.filter,
|
|
73
|
-
context: {
|
|
74
|
+
context: projectWorkflowPredicateContext('trigger.filter', {
|
|
74
75
|
event: params.payload,
|
|
75
76
|
trigger: {source: params.source, event: params.event},
|
|
76
|
-
},
|
|
77
|
+
}),
|
|
77
78
|
invalidReason: 'Trigger subscription filter must be a non-empty string when set',
|
|
78
79
|
evaluationFailedReason: 'Trigger filter evaluation failed',
|
|
79
80
|
});
|
|
@@ -2,6 +2,7 @@ import {triggerSubscriptionFactory} from '#test/index.js';
|
|
|
2
2
|
|
|
3
3
|
const runWorkflow = vi.fn();
|
|
4
4
|
const deliverEventToListener = vi.fn();
|
|
5
|
+
const resolveWorkflowRunTriggerReference = vi.fn();
|
|
5
6
|
const insertReceivedEvent = vi.fn();
|
|
6
7
|
|
|
7
8
|
vi.mock('#db/event-history.js', () => ({
|
|
@@ -24,6 +25,8 @@ const {dispatchIntegrationEvent} = await import('./dispatch-integration-event.js
|
|
|
24
25
|
const workflows = {
|
|
25
26
|
startRunFromTrigger: (...args: unknown[]) => runWorkflow(...args),
|
|
26
27
|
deliverEventToJobListener: (...args: unknown[]) => deliverEventToListener(...args),
|
|
28
|
+
resolveWorkflowRunTriggerReference: (...args: unknown[]) =>
|
|
29
|
+
resolveWorkflowRunTriggerReference(...args),
|
|
27
30
|
getStepLogContext: async () => ({harness: 'pi' as const}),
|
|
28
31
|
getLeasedAgentToolContext: async () => ({
|
|
29
32
|
workspaceId: crypto.randomUUID(),
|
|
@@ -35,6 +38,8 @@ describe('dispatchIntegrationEvent resilience to history-write failure', () => {
|
|
|
35
38
|
beforeEach(() => {
|
|
36
39
|
runWorkflow.mockReset();
|
|
37
40
|
deliverEventToListener.mockReset();
|
|
41
|
+
resolveWorkflowRunTriggerReference.mockReset();
|
|
42
|
+
resolveWorkflowRunTriggerReference.mockResolvedValue(null);
|
|
38
43
|
deliverEventToListener.mockResolvedValue({buffered: true, skipped: false});
|
|
39
44
|
insertReceivedEvent.mockReset();
|
|
40
45
|
insertReceivedEvent.mockRejectedValue(new Error('history db down'));
|
|
@@ -8,12 +8,15 @@ import {jobListenerSubscriptionFactory, triggerSubscriptionFactory} from '#test/
|
|
|
8
8
|
|
|
9
9
|
const runWorkflow = vi.fn();
|
|
10
10
|
const deliverEventToListener = vi.fn();
|
|
11
|
+
const resolveWorkflowRunTriggerReference = vi.fn();
|
|
11
12
|
|
|
12
13
|
const {dispatchIntegrationEvent} = await import('./dispatch-integration-event.js');
|
|
13
14
|
|
|
14
15
|
const workflows = {
|
|
15
16
|
startRunFromTrigger: (...args: unknown[]) => runWorkflow(...args),
|
|
16
17
|
deliverEventToJobListener: (...args: unknown[]) => deliverEventToListener(...args),
|
|
18
|
+
resolveWorkflowRunTriggerReference: (...args: unknown[]) =>
|
|
19
|
+
resolveWorkflowRunTriggerReference(...args),
|
|
17
20
|
getStepLogContext: async () => ({harness: 'pi' as const}),
|
|
18
21
|
getLeasedAgentToolContext: async () => ({
|
|
19
22
|
workspaceId: crypto.randomUUID(),
|
|
@@ -84,8 +87,10 @@ describe('dispatchIntegrationEvent', () => {
|
|
|
84
87
|
beforeEach(() => {
|
|
85
88
|
runWorkflow.mockReset();
|
|
86
89
|
deliverEventToListener.mockReset();
|
|
90
|
+
resolveWorkflowRunTriggerReference.mockReset();
|
|
87
91
|
runWorkflow.mockResolvedValue({id: crypto.randomUUID(), name: 'Build and test'});
|
|
88
92
|
deliverEventToListener.mockResolvedValue({buffered: true, skipped: false});
|
|
93
|
+
resolveWorkflowRunTriggerReference.mockResolvedValue(null);
|
|
89
94
|
});
|
|
90
95
|
|
|
91
96
|
test('fires the workflow for each matching workspace subscription, regardless of project', async () => {
|
|
@@ -114,6 +119,7 @@ describe('dispatchIntegrationEvent', () => {
|
|
|
114
119
|
|
|
115
120
|
test('passes the source, event, deliveryId and raw payload through as the trigger payload', async () => {
|
|
116
121
|
const workspaceId = crypto.randomUUID();
|
|
122
|
+
const connectionId = crypto.randomUUID();
|
|
117
123
|
const deliveryId = crypto.randomUUID();
|
|
118
124
|
const payload = {ref: 'refs/heads/feature', headCommitSha: 'deadbeef'};
|
|
119
125
|
await triggerSubscriptionFactory.create({
|
|
@@ -123,10 +129,11 @@ describe('dispatchIntegrationEvent', () => {
|
|
|
123
129
|
config: {},
|
|
124
130
|
});
|
|
125
131
|
|
|
126
|
-
await dispatch({workspaceId, deliveryId, payload});
|
|
132
|
+
await dispatch({workspaceId, connectionId, deliveryId, payload});
|
|
127
133
|
|
|
128
134
|
expect(runWorkflow).toHaveBeenCalledWith(
|
|
129
135
|
expect.objectContaining({
|
|
136
|
+
triggerConnectionId: connectionId,
|
|
130
137
|
triggerPayload: {
|
|
131
138
|
provider: 'github',
|
|
132
139
|
source: 'github',
|
|
@@ -87,6 +87,7 @@ export async function dispatchIntegrationEvent(
|
|
|
87
87
|
workspaceId: subscription.workspaceId,
|
|
88
88
|
projectId: subscription.projectId,
|
|
89
89
|
definitionId: subscription.workflowDefinitionId,
|
|
90
|
+
triggerConnectionId: params.connectionId,
|
|
90
91
|
triggerPayload: {
|
|
91
92
|
provider: params.provider,
|
|
92
93
|
source: params.source,
|
|
@@ -116,6 +117,7 @@ export async function dispatchIntegrationEvent(
|
|
|
116
117
|
history,
|
|
117
118
|
eventRef: params.eventRef,
|
|
118
119
|
workspaceId: params.workspaceId,
|
|
120
|
+
connectionId: params.connectionId,
|
|
119
121
|
provider: params.provider,
|
|
120
122
|
source: params.source,
|
|
121
123
|
event: params.event,
|
|
@@ -4,6 +4,7 @@ import {jobListenerSubscriptionFactory} from '#test/index.js';
|
|
|
4
4
|
import type {TriggerHistoryRecorder} from './record-trigger-history.js';
|
|
5
5
|
|
|
6
6
|
const deliverEventToListener = vi.fn();
|
|
7
|
+
const resolveWorkflowRunTriggerReference = vi.fn();
|
|
7
8
|
const findMatchingJobListenerSubscriptions = vi.fn();
|
|
8
9
|
const listenerTriggered = vi.fn();
|
|
9
10
|
const listenerFilterErrored = vi.fn();
|
|
@@ -33,11 +34,14 @@ const {routeEventToJobListeners} = await import('./route-event-to-job-listeners.
|
|
|
33
34
|
|
|
34
35
|
const workflows = {
|
|
35
36
|
deliverEventToJobListener: (...args: unknown[]) => deliverEventToListener(...args),
|
|
37
|
+
resolveWorkflowRunTriggerReference: (...args: unknown[]) =>
|
|
38
|
+
resolveWorkflowRunTriggerReference(...args),
|
|
36
39
|
} as never;
|
|
37
40
|
|
|
38
41
|
interface RouteOverrides {
|
|
39
42
|
eventRef?: string;
|
|
40
43
|
workspaceId?: string;
|
|
44
|
+
connectionId?: string;
|
|
41
45
|
source?: string;
|
|
42
46
|
event?: string;
|
|
43
47
|
payload?: unknown;
|
|
@@ -49,6 +53,7 @@ function route(overrides: RouteOverrides = {}) {
|
|
|
49
53
|
history: buildHistory(),
|
|
50
54
|
eventRef: overrides.eventRef ?? crypto.randomUUID(),
|
|
51
55
|
workspaceId: overrides.workspaceId ?? crypto.randomUUID(),
|
|
56
|
+
connectionId: overrides.connectionId ?? crypto.randomUUID(),
|
|
52
57
|
provider: 'github',
|
|
53
58
|
source: overrides.source ?? 'github',
|
|
54
59
|
event: overrides.event ?? 'pull_request_review',
|
|
@@ -76,12 +81,14 @@ function buildHistory(): TriggerHistoryRecorder {
|
|
|
76
81
|
describe('routeEventToJobListeners', () => {
|
|
77
82
|
beforeEach(() => {
|
|
78
83
|
deliverEventToListener.mockReset();
|
|
84
|
+
resolveWorkflowRunTriggerReference.mockReset();
|
|
79
85
|
findMatchingJobListenerSubscriptions.mockReset();
|
|
80
86
|
listenerTriggered.mockReset();
|
|
81
87
|
listenerFilterErrored.mockReset();
|
|
82
88
|
listenerDispatchErrored.mockReset();
|
|
83
89
|
loggerWarn.mockReset();
|
|
84
90
|
deliverEventToListener.mockResolvedValue({buffered: true, skipped: false});
|
|
91
|
+
resolveWorkflowRunTriggerReference.mockResolvedValue(null);
|
|
85
92
|
});
|
|
86
93
|
|
|
87
94
|
it('computes resolve as the effective disposition when on and until match one job', async () => {
|
|
@@ -121,6 +128,72 @@ describe('routeEventToJobListeners', () => {
|
|
|
121
128
|
});
|
|
122
129
|
});
|
|
123
130
|
|
|
131
|
+
it('passes the source connection to listener delivery', async () => {
|
|
132
|
+
const workspaceId = crypto.randomUUID();
|
|
133
|
+
const connectionId = crypto.randomUUID();
|
|
134
|
+
const jobId = crypto.randomUUID();
|
|
135
|
+
await jobListenerSubscriptionFactory.create({
|
|
136
|
+
workspaceId,
|
|
137
|
+
jobId,
|
|
138
|
+
kind: 'on',
|
|
139
|
+
matcherOrdinal: 0,
|
|
140
|
+
source: 'github',
|
|
141
|
+
event: 'pull_request_review',
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
await route({workspaceId, connectionId});
|
|
145
|
+
|
|
146
|
+
expect(deliverEventToListener).toHaveBeenCalledWith(
|
|
147
|
+
expect.objectContaining({jobId, triggerConnectionId: connectionId}),
|
|
148
|
+
);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('resolves one trigger reference for all fire deliveries', async () => {
|
|
152
|
+
const workspaceId = crypto.randomUUID();
|
|
153
|
+
const connectionId = crypto.randomUUID();
|
|
154
|
+
const triggerReference = {
|
|
155
|
+
project: {id: crypto.randomUUID()},
|
|
156
|
+
repository: 'acme/api',
|
|
157
|
+
ref: 'refs/heads/main',
|
|
158
|
+
commit: 'a'.repeat(40),
|
|
159
|
+
};
|
|
160
|
+
for (const jobId of [crypto.randomUUID(), crypto.randomUUID()]) {
|
|
161
|
+
await jobListenerSubscriptionFactory.create({
|
|
162
|
+
workspaceId,
|
|
163
|
+
jobId,
|
|
164
|
+
kind: 'on',
|
|
165
|
+
matcherOrdinal: 0,
|
|
166
|
+
source: 'github',
|
|
167
|
+
event: 'pull_request_review',
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
resolveWorkflowRunTriggerReference.mockResolvedValue(triggerReference);
|
|
171
|
+
|
|
172
|
+
await route({workspaceId, connectionId});
|
|
173
|
+
|
|
174
|
+
expect(resolveWorkflowRunTriggerReference).toHaveBeenCalledTimes(1);
|
|
175
|
+
expect(resolveWorkflowRunTriggerReference).toHaveBeenCalledWith({
|
|
176
|
+
workspaceId,
|
|
177
|
+
triggerConnectionId: connectionId,
|
|
178
|
+
triggerPayload: {
|
|
179
|
+
provider: 'github',
|
|
180
|
+
source: 'github',
|
|
181
|
+
event: 'pull_request_review',
|
|
182
|
+
deliveryId: expect.any(String),
|
|
183
|
+
data: {action: 'submitted'},
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
expect(deliverEventToListener).toHaveBeenCalledTimes(2);
|
|
187
|
+
expect(deliverEventToListener).toHaveBeenNthCalledWith(
|
|
188
|
+
1,
|
|
189
|
+
expect.objectContaining({triggerReference}),
|
|
190
|
+
);
|
|
191
|
+
expect(deliverEventToListener).toHaveBeenNthCalledWith(
|
|
192
|
+
2,
|
|
193
|
+
expect.objectContaining({triggerReference}),
|
|
194
|
+
);
|
|
195
|
+
});
|
|
196
|
+
|
|
124
197
|
it('does not deliver when no listener subscription matches', async () => {
|
|
125
198
|
const workspaceId = crypto.randomUUID();
|
|
126
199
|
await jobListenerSubscriptionFactory.create({
|
|
@@ -266,6 +339,32 @@ describe('routeEventToJobListeners', () => {
|
|
|
266
339
|
expect(result).toMatchObject({engagedCount: 0, matchedJobCount: 0, acceptedJobCount: 0});
|
|
267
340
|
});
|
|
268
341
|
|
|
342
|
+
it.each([
|
|
343
|
+
'on',
|
|
344
|
+
'until',
|
|
345
|
+
] as const)('fails closed when a %s listener snapshot contains a foreign root', async (kind) => {
|
|
346
|
+
const workspaceId = crypto.randomUUID();
|
|
347
|
+
await jobListenerSubscriptionFactory.create({
|
|
348
|
+
workspaceId,
|
|
349
|
+
kind,
|
|
350
|
+
source: 'github',
|
|
351
|
+
event: 'pull_request_review',
|
|
352
|
+
config: {
|
|
353
|
+
filter: 'executions.size() > 0',
|
|
354
|
+
filter_snapshot: {executions: [{status: 'succeeded'}]},
|
|
355
|
+
},
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
const result = await route({workspaceId});
|
|
359
|
+
|
|
360
|
+
expect(deliverEventToListener).not.toHaveBeenCalled();
|
|
361
|
+
expect(listenerFilterErrored).toHaveBeenCalledWith(
|
|
362
|
+
expect.objectContaining({kind}),
|
|
363
|
+
'Listener filter evaluation failed',
|
|
364
|
+
);
|
|
365
|
+
expect(result).toMatchObject({engagedCount: 1, matchedJobCount: 0, acceptedJobCount: 0});
|
|
366
|
+
});
|
|
367
|
+
|
|
269
368
|
it.each([
|
|
270
369
|
{name: 'missing jobs snapshot', filterSnapshot: {}},
|
|
271
370
|
{name: 'empty jobs snapshot', filterSnapshot: {jobs: {}}},
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {projectWorkflowPredicateContext} from '@shipfox/expression';
|
|
1
2
|
import {logger} from '@shipfox/node-opentelemetry';
|
|
2
3
|
import {findMatchingJobListenerSubscriptions} from '#db/job-listener-subscriptions.js';
|
|
3
4
|
import {evaluateStoredFilter, type StoredFilterEvaluation} from './config.js';
|
|
@@ -13,6 +14,7 @@ export interface RouteEventToJobListenersParams {
|
|
|
13
14
|
history: TriggerHistoryRecorder;
|
|
14
15
|
eventRef: string;
|
|
15
16
|
workspaceId: string;
|
|
17
|
+
connectionId: string;
|
|
16
18
|
provider: string;
|
|
17
19
|
source: string;
|
|
18
20
|
event: string;
|
|
@@ -65,6 +67,23 @@ export async function routeEventToJobListeners(
|
|
|
65
67
|
}
|
|
66
68
|
}
|
|
67
69
|
|
|
70
|
+
const fireDeliveryNeeded = [...effectiveMatchByJobId.values()].some(
|
|
71
|
+
(subscription) => listenerDisposition(subscription) === 'fire',
|
|
72
|
+
);
|
|
73
|
+
const triggerReference = fireDeliveryNeeded
|
|
74
|
+
? await params.workflows.resolveWorkflowRunTriggerReference({
|
|
75
|
+
workspaceId: params.workspaceId,
|
|
76
|
+
triggerConnectionId: params.connectionId,
|
|
77
|
+
triggerPayload: {
|
|
78
|
+
provider: params.provider,
|
|
79
|
+
source: params.source,
|
|
80
|
+
event: params.event,
|
|
81
|
+
deliveryId: params.deliveryId,
|
|
82
|
+
data: params.payload,
|
|
83
|
+
},
|
|
84
|
+
})
|
|
85
|
+
: null;
|
|
86
|
+
|
|
68
87
|
let acceptedJobCount = 0;
|
|
69
88
|
let deliveredCount = 0;
|
|
70
89
|
let sawTransientError = false;
|
|
@@ -82,6 +101,8 @@ export async function routeEventToJobListeners(
|
|
|
82
101
|
source: params.source,
|
|
83
102
|
event: params.event,
|
|
84
103
|
provider: params.provider,
|
|
104
|
+
triggerConnectionId: params.connectionId,
|
|
105
|
+
triggerReference,
|
|
85
106
|
payload: params.payload,
|
|
86
107
|
receivedAt: params.receivedAt.toISOString(),
|
|
87
108
|
});
|
|
@@ -121,7 +142,10 @@ function evaluateListenerFilter(params: EvaluateListenerFilterParams): StoredFil
|
|
|
121
142
|
if (typeof filter !== 'string' || filter.trim() === '') {
|
|
122
143
|
return evaluateStoredFilter({
|
|
123
144
|
value: filter,
|
|
124
|
-
context:
|
|
145
|
+
context: projectWorkflowPredicateContext(
|
|
146
|
+
params.subscription.kind === 'on' ? 'listener.on' : 'listener.until',
|
|
147
|
+
{event: params.payload},
|
|
148
|
+
),
|
|
125
149
|
invalidReason: 'Listener subscription filter must be a non-empty string when set',
|
|
126
150
|
evaluationFailedReason: 'Listener filter evaluation failed',
|
|
127
151
|
});
|
|
@@ -132,7 +156,10 @@ function evaluateListenerFilter(params: EvaluateListenerFilterParams): StoredFil
|
|
|
132
156
|
|
|
133
157
|
return evaluateStoredFilter({
|
|
134
158
|
value: filter,
|
|
135
|
-
context:
|
|
159
|
+
context: projectWorkflowPredicateContext(
|
|
160
|
+
params.subscription.kind === 'on' ? 'listener.on' : 'listener.until',
|
|
161
|
+
{...snapshot.value, event: params.payload},
|
|
162
|
+
),
|
|
136
163
|
invalidReason: 'Listener subscription filter must be a non-empty string when set',
|
|
137
164
|
evaluationFailedReason: 'Listener filter evaluation failed',
|
|
138
165
|
});
|
|
@@ -52,6 +52,7 @@ function localWorkflowsClient(): WorkflowsModuleClient {
|
|
|
52
52
|
{definitionId},
|
|
53
53
|
);
|
|
54
54
|
},
|
|
55
|
+
resolveWorkflowRunTriggerReference: () => null,
|
|
55
56
|
deliverEventToJobListener: ({jobId}) => {
|
|
56
57
|
if (jobId === listenerErrorInput.jobId) {
|
|
57
58
|
throw createInterModuleKnownError(
|
|
@@ -92,6 +93,13 @@ async function runConsumerSuite(client: WorkflowsModuleClient): Promise<void> {
|
|
|
92
93
|
buffered: true,
|
|
93
94
|
skipped: false,
|
|
94
95
|
});
|
|
96
|
+
await expect(
|
|
97
|
+
client.resolveWorkflowRunTriggerReference({
|
|
98
|
+
workspaceId: input.workspaceId,
|
|
99
|
+
triggerConnectionId: crypto.randomUUID(),
|
|
100
|
+
triggerPayload: input.triggerPayload,
|
|
101
|
+
}),
|
|
102
|
+
).resolves.toBeNull();
|
|
95
103
|
await expect(client.deliverEventToJobListener(listenerErrorInput)).rejects.toMatchObject({
|
|
96
104
|
code: 'workspace-not-found',
|
|
97
105
|
details: {workspaceId: input.workspaceId},
|
|
@@ -22,7 +22,11 @@ const workflows = {} as WorkflowsModuleClient;
|
|
|
22
22
|
describe('POST /:definitionId/fire-manual', () => {
|
|
23
23
|
let app: FastifyInstance;
|
|
24
24
|
let workspaceId: string;
|
|
25
|
-
let memberships: Array<{
|
|
25
|
+
let memberships: Array<{
|
|
26
|
+
workspaceId: string;
|
|
27
|
+
role: 'admin';
|
|
28
|
+
workspaceStatus: 'active' | 'suspended' | 'deleted';
|
|
29
|
+
}>;
|
|
26
30
|
|
|
27
31
|
beforeAll(async () => {
|
|
28
32
|
app = Fastify();
|
|
@@ -41,7 +45,7 @@ describe('POST /:definitionId/fire-manual', () => {
|
|
|
41
45
|
|
|
42
46
|
beforeEach(() => {
|
|
43
47
|
workspaceId = crypto.randomUUID();
|
|
44
|
-
memberships = [{workspaceId, role: 'admin'}];
|
|
48
|
+
memberships = [{workspaceId, role: 'admin', workspaceStatus: 'active'}];
|
|
45
49
|
fireManualSubscriptionMock.mockReset();
|
|
46
50
|
});
|
|
47
51
|
|
|
@@ -89,6 +93,22 @@ describe('POST /:definitionId/fire-manual', () => {
|
|
|
89
93
|
});
|
|
90
94
|
});
|
|
91
95
|
|
|
96
|
+
test('returns workspace-suspended for a suspended membership claim', async () => {
|
|
97
|
+
const definitionId = crypto.randomUUID();
|
|
98
|
+
await triggerSubscriptionFactory.create({workspaceId, workflowDefinitionId: definitionId});
|
|
99
|
+
memberships = [{workspaceId, role: 'admin', workspaceStatus: 'suspended'}];
|
|
100
|
+
|
|
101
|
+
const res = await app.inject({
|
|
102
|
+
method: 'POST',
|
|
103
|
+
url: `/${definitionId}/fire-manual`,
|
|
104
|
+
payload: {},
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
expect(res.statusCode).toBe(409);
|
|
108
|
+
expect(res.json().code).toBe('workspace-suspended');
|
|
109
|
+
expect(fireManualSubscriptionMock).not.toHaveBeenCalled();
|
|
110
|
+
});
|
|
111
|
+
|
|
92
112
|
test('maps suspended workspace to 409', async () => {
|
|
93
113
|
const definitionId = crypto.randomUUID();
|
|
94
114
|
await triggerSubscriptionFactory.create({workspaceId, workflowDefinitionId: definitionId});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {requireUserContext} from '@shipfox/api-auth-context';
|
|
1
|
+
import {requireUserContext, requireWorkspaceResourceAccess} from '@shipfox/api-auth-context';
|
|
2
2
|
import {
|
|
3
3
|
fireManualTriggerBodySchema,
|
|
4
4
|
fireManualTriggerResponseSchema,
|
|
@@ -81,10 +81,18 @@ export function createFireManualTriggerRoute(workflows: WorkflowsModuleClient) {
|
|
|
81
81
|
const userContext = requireUserContext(request);
|
|
82
82
|
|
|
83
83
|
const subscription = await getManualSubscriptionByDefinitionId(definitionId);
|
|
84
|
-
//
|
|
85
|
-
|
|
84
|
+
// Missing triggers and memberships remain 404 to avoid leaking existence; lifecycle claims
|
|
85
|
+
// propagate their stable access errors.
|
|
86
|
+
if (!subscription) {
|
|
86
87
|
throw new ManualTriggerNotFoundError(definitionId);
|
|
87
88
|
}
|
|
89
|
+
requireWorkspaceResourceAccess({
|
|
90
|
+
request,
|
|
91
|
+
workspaceId: subscription.workspaceId,
|
|
92
|
+
notFoundError: new ClientError('Manual trigger not found', 'manual-trigger-not-found', {
|
|
93
|
+
status: 404,
|
|
94
|
+
}),
|
|
95
|
+
});
|
|
88
96
|
|
|
89
97
|
const run = await fireManualSubscription({
|
|
90
98
|
workflows,
|
|
@@ -10,7 +10,11 @@ import {getTriggerEventRoute} from './get-trigger-event.js';
|
|
|
10
10
|
describe('GET /trigger-events/:id', () => {
|
|
11
11
|
let app: FastifyInstance;
|
|
12
12
|
let workspaceId: string;
|
|
13
|
-
let memberships: Array<{
|
|
13
|
+
let memberships: Array<{
|
|
14
|
+
workspaceId: string;
|
|
15
|
+
role: 'admin';
|
|
16
|
+
workspaceStatus: 'active' | 'suspended' | 'deleted';
|
|
17
|
+
}>;
|
|
14
18
|
|
|
15
19
|
beforeAll(async () => {
|
|
16
20
|
app = Fastify();
|
|
@@ -29,7 +33,7 @@ describe('GET /trigger-events/:id', () => {
|
|
|
29
33
|
|
|
30
34
|
beforeEach(() => {
|
|
31
35
|
workspaceId = crypto.randomUUID();
|
|
32
|
-
memberships = [{workspaceId, role: 'admin'}];
|
|
36
|
+
memberships = [{workspaceId, role: 'admin', workspaceStatus: 'active'}];
|
|
33
37
|
});
|
|
34
38
|
|
|
35
39
|
test('returns the event with its decisions and full payload', async () => {
|
|
@@ -160,6 +164,16 @@ describe('GET /trigger-events/:id', () => {
|
|
|
160
164
|
expect(res.json().decisions).toEqual([]);
|
|
161
165
|
});
|
|
162
166
|
|
|
167
|
+
test('returns workspace-suspended for a suspended membership claim', async () => {
|
|
168
|
+
const event = await receivedEventFactory.create({workspaceId});
|
|
169
|
+
memberships = [{workspaceId, role: 'admin', workspaceStatus: 'suspended'}];
|
|
170
|
+
|
|
171
|
+
const res = await app.inject({method: 'GET', url: `/trigger-events/${event.id}`});
|
|
172
|
+
|
|
173
|
+
expect(res.statusCode).toBe(409);
|
|
174
|
+
expect(res.json().code).toBe('workspace-suspended');
|
|
175
|
+
});
|
|
176
|
+
|
|
163
177
|
test('returns 404 for an unknown event id', async () => {
|
|
164
178
|
const res = await app.inject({method: 'GET', url: `/trigger-events/${crypto.randomUUID()}`});
|
|
165
179
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {requireWorkspaceResourceAccess} from '@shipfox/api-auth-context';
|
|
2
2
|
import {triggerEventDetailResponseSchema} from '@shipfox/api-triggers-dto';
|
|
3
3
|
import {ClientError, defineRoute} from '@shipfox/node-fastify';
|
|
4
4
|
import {z} from 'zod';
|
|
@@ -19,13 +19,18 @@ export const getTriggerEventRoute = defineRoute({
|
|
|
19
19
|
},
|
|
20
20
|
handler: async (request) => {
|
|
21
21
|
const {id} = request.params;
|
|
22
|
-
const userContext = requireUserContext(request);
|
|
23
22
|
|
|
24
23
|
const event = await getTriggerEventById(id);
|
|
25
|
-
//
|
|
26
|
-
|
|
24
|
+
// Missing events and memberships remain 404 to avoid leaking existence; lifecycle claims
|
|
25
|
+
// propagate their stable access errors.
|
|
26
|
+
if (!event) {
|
|
27
27
|
throw new ClientError('Trigger event not found', 'not-found', {status: 404});
|
|
28
28
|
}
|
|
29
|
+
requireWorkspaceResourceAccess({
|
|
30
|
+
request,
|
|
31
|
+
workspaceId: event.workspaceId,
|
|
32
|
+
notFoundError: new ClientError('Trigger event not found', 'not-found', {status: 404}),
|
|
33
|
+
});
|
|
29
34
|
|
|
30
35
|
const decisions = await listDecisionsByReceivedEventId(event.id);
|
|
31
36
|
|
|
@@ -13,7 +13,11 @@ const facets = (res: {
|
|
|
13
13
|
describe('GET /trigger-events/facets', () => {
|
|
14
14
|
let app: FastifyInstance;
|
|
15
15
|
let workspaceId: string;
|
|
16
|
-
let memberships: Array<{
|
|
16
|
+
let memberships: Array<{
|
|
17
|
+
workspaceId: string;
|
|
18
|
+
role: 'admin';
|
|
19
|
+
workspaceStatus: 'active' | 'suspended' | 'deleted';
|
|
20
|
+
}>;
|
|
17
21
|
|
|
18
22
|
beforeAll(async () => {
|
|
19
23
|
app = Fastify();
|
|
@@ -32,7 +36,7 @@ describe('GET /trigger-events/facets', () => {
|
|
|
32
36
|
|
|
33
37
|
beforeEach(() => {
|
|
34
38
|
workspaceId = crypto.randomUUID();
|
|
35
|
-
memberships = [{workspaceId, role: 'admin'}];
|
|
39
|
+
memberships = [{workspaceId, role: 'admin', workspaceStatus: 'active'}];
|
|
36
40
|
});
|
|
37
41
|
|
|
38
42
|
test('returns distinct sources and events with counts, ordered by count desc', async () => {
|
|
@@ -93,6 +97,18 @@ describe('GET /trigger-events/facets', () => {
|
|
|
93
97
|
expect(res.json()).toEqual({sources: [], events: []});
|
|
94
98
|
});
|
|
95
99
|
|
|
100
|
+
test('returns workspace-suspended for a suspended membership claim', async () => {
|
|
101
|
+
memberships = [{workspaceId, role: 'admin', workspaceStatus: 'suspended'}];
|
|
102
|
+
|
|
103
|
+
const res = await app.inject({
|
|
104
|
+
method: 'GET',
|
|
105
|
+
url: `/trigger-events/facets?workspace_id=${workspaceId}`,
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
expect(res.statusCode).toBe(409);
|
|
109
|
+
expect(res.json().code).toBe('workspace-suspended');
|
|
110
|
+
});
|
|
111
|
+
|
|
96
112
|
test('denies a workspace the caller does not belong to', async () => {
|
|
97
113
|
const otherWorkspaceId = crypto.randomUUID();
|
|
98
114
|
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {requireWorkspaceAccess} from '@shipfox/api-auth-context';
|
|
2
2
|
import {
|
|
3
3
|
triggerEventFacetsQuerySchema,
|
|
4
4
|
triggerEventFacetsResponseSchema,
|
|
5
5
|
} from '@shipfox/api-triggers-dto';
|
|
6
|
-
import {
|
|
6
|
+
import {defineRoute} from '@shipfox/node-fastify';
|
|
7
7
|
import {listTriggerEventFacets} from '#db/index.js';
|
|
8
8
|
|
|
9
9
|
export const listTriggerEventFacetsRoute = defineRoute({
|
|
@@ -19,10 +19,7 @@ export const listTriggerEventFacetsRoute = defineRoute({
|
|
|
19
19
|
handler: async (request) => {
|
|
20
20
|
const {workspace_id: workspaceId} = request.query;
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
if (!userContext.canAccess(workspaceId)) {
|
|
24
|
-
throw new ClientError('Not a member of this workspace', 'forbidden', {status: 403});
|
|
25
|
-
}
|
|
22
|
+
requireWorkspaceAccess({request, workspaceId});
|
|
26
23
|
|
|
27
24
|
return await listTriggerEventFacets({workspaceId});
|
|
28
25
|
},
|