@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.
Files changed (46) hide show
  1. package/.turbo/turbo-build.log +4 -4
  2. package/CHANGELOG.md +67 -0
  3. package/README.md +12 -12
  4. package/dist/core/config.d.ts.map +1 -1
  5. package/dist/core/config.js +3 -3
  6. package/dist/core/config.js.map +1 -1
  7. package/dist/core/dispatch-integration-event.d.ts.map +1 -1
  8. package/dist/core/dispatch-integration-event.js +2 -0
  9. package/dist/core/dispatch-integration-event.js.map +1 -1
  10. package/dist/core/route-event-to-job-listeners.d.ts +1 -0
  11. package/dist/core/route-event-to-job-listeners.d.ts.map +1 -1
  12. package/dist/core/route-event-to-job-listeners.js +21 -4
  13. package/dist/core/route-event-to-job-listeners.js.map +1 -1
  14. package/dist/core/workflows-client.d.ts +10 -1
  15. package/dist/core/workflows-client.d.ts.map +1 -1
  16. package/dist/presentation/routes/fire-manual.d.ts.map +1 -1
  17. package/dist/presentation/routes/fire-manual.js +11 -3
  18. package/dist/presentation/routes/fire-manual.js.map +1 -1
  19. package/dist/presentation/routes/get-trigger-event.d.ts.map +1 -1
  20. package/dist/presentation/routes/get-trigger-event.js +11 -4
  21. package/dist/presentation/routes/get-trigger-event.js.map +1 -1
  22. package/dist/presentation/routes/list-trigger-event-facets.d.ts.map +1 -1
  23. package/dist/presentation/routes/list-trigger-event-facets.js +6 -8
  24. package/dist/presentation/routes/list-trigger-event-facets.js.map +1 -1
  25. package/dist/presentation/routes/list-trigger-events.d.ts.map +1 -1
  26. package/dist/presentation/routes/list-trigger-events.js +5 -7
  27. package/dist/presentation/routes/list-trigger-events.js.map +1 -1
  28. package/dist/tsconfig.test.tsbuildinfo +1 -1
  29. package/package.json +13 -13
  30. package/src/core/config.test.ts +23 -0
  31. package/src/core/config.ts +3 -2
  32. package/src/core/dispatch-integration-event.history-failure.test.ts +5 -0
  33. package/src/core/dispatch-integration-event.test.ts +8 -1
  34. package/src/core/dispatch-integration-event.ts +2 -0
  35. package/src/core/route-event-to-job-listeners.test.ts +99 -0
  36. package/src/core/route-event-to-job-listeners.ts +29 -2
  37. package/src/core/workflows-client.test.ts +8 -0
  38. package/src/presentation/routes/fire-manual.test.ts +22 -2
  39. package/src/presentation/routes/fire-manual.ts +11 -3
  40. package/src/presentation/routes/get-trigger-event.test.ts +16 -2
  41. package/src/presentation/routes/get-trigger-event.ts +9 -4
  42. package/src/presentation/routes/list-trigger-event-facets.test.ts +18 -2
  43. package/src/presentation/routes/list-trigger-event-facets.ts +3 -6
  44. package/src/presentation/routes/list-trigger-events.test.ts +18 -2
  45. package/src/presentation/routes/list-trigger-events.ts +2 -5
  46. 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": "10.2.0",
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-definitions-dto": "10.0.0",
26
- "@shipfox/api-auth-context": "10.2.0",
27
- "@shipfox/api-integration-core-dto": "9.0.2",
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/expression": "1.2.0",
32
- "@shipfox/inter-module": "0.2.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-fastify": "0.4.0",
35
- "@shipfox/node-drizzle": "0.3.4",
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/workflow-document": "2.1.3",
41
- "@shipfox/node-temporal": "0.4.4"
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/*"
@@ -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
 
@@ -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: {event: params.payload},
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: {...snapshot.value, event: params.payload},
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<{workspaceId: string; role: 'admin'}>;
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
- // 404 covers both "no such manual trigger" and "not your workspace" to avoid leaking existence.
85
- if (!subscription || !userContext.canAccess(subscription.workspaceId)) {
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<{workspaceId: string; role: 'admin'}>;
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 {requireUserContext} from '@shipfox/api-auth-context';
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
- // 404 covers both "no such event" and "not your workspace" to avoid leaking existence.
26
- if (!event || !userContext.canAccess(event.workspaceId)) {
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<{workspaceId: string; role: 'admin'}>;
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 {requireUserContext} from '@shipfox/api-auth-context';
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 {ClientError, defineRoute} from '@shipfox/node-fastify';
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
- const userContext = requireUserContext(request);
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
  },