@gadmin2n/schematics 0.0.87 → 0.0.89
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/dist/lib/application/files/gadmin2-game-angle-demo/.dockerignore +16 -2
- package/dist/lib/application/files/gadmin2-game-angle-demo/Dockerfile.codegen +40 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/Dockerfile.server +76 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/Dockerfile.web +53 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/Jenkinsfile +219 -33
- package/dist/lib/application/files/gadmin2-game-angle-demo/compose-ctl.sh +250 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/config/prisma/workflow.prisma +4 -1
- package/dist/lib/application/files/gadmin2-game-angle-demo/dev/postgres/init.sql +12 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/docker-compose.md +170 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/docker-compose.yml +254 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/package.json +8 -7
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/scripts/lib/page-helpers.ts +1 -1
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/scripts/prismaModels.ts +1 -1
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/seed/agenda.seed.ts +39 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/seed/audit.seed.ts +40 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/seed/bootstrap.ts +56 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/seed/canvas.seed.ts +39 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/{scripts/sync-data-mngt-pages.ts → seed/data-mngt.seed.ts} +36 -20
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/seed/game.seed.ts +44 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/seed/index.ts +30 -6
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/seed/permission.seed.ts +130 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/seed/workflow-event-trigger.ts +60 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/seed/workflow-node-types.ts +11 -25
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/seed/workflow.seed.ts +108 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/main.ts +1 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/agendaJob/agendaJob.controller.spec.ts +31 -2
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/audit/audit.controller.spec.ts +31 -2
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/audit/audit.service.spec.ts +41 -57
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/game/game.controller.spec.ts +31 -2
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/game/game.service.spec.ts +309 -1
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/page/page.controller.spec.ts +31 -2
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/page/page.service.spec.ts +315 -1
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/pageResource/pageResource.controller.spec.ts +31 -2
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/pageResource/pageResource.service.spec.ts +312 -2
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/resource/resource.controller.spec.ts +31 -2
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/resource/resource.service.spec.ts +317 -1
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/role/role.controller.spec.ts +31 -2
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/role/role.service.spec.ts +309 -1
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/rolePages/rolePages.controller.spec.ts +31 -2
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/rolePages/rolePages.service.spec.ts +299 -1
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/roleResource/roleResource.controller.spec.ts +31 -2
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/roleResource/roleResource.service.spec.ts +307 -1
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/user/user.controller.spec.ts +31 -2
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/user/user.service.spec.ts +309 -1
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/dsl-validate.util.spec.ts +205 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/dsl-validate.util.ts +116 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/temporal.service.spec.ts +158 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/temporal.service.ts +110 -1
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/webhook-signature.util.spec.ts +79 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/webhook-signature.util.ts +54 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow.controller.ts +34 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow.service.spec.ts +457 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow.service.ts +241 -4
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflowEventOutbox/workflowEventOutbox.controller.spec.ts +34 -2
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflowEventOutbox/workflowEventOutbox.service.spec.ts +24 -30
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflowNodeInstance/workflowNodeInstance.controller.spec.ts +34 -2
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflowNodeInstance/workflowNodeInstance.service.spec.ts +36 -36
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflowNodeType/workflowNodeType.controller.spec.ts +34 -2
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflowNodeType/workflowNodeType.service.spec.ts +48 -24
- package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/README.md +312 -3
- package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/TODO.md +152 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/worker/.dockerignore +12 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/worker/Dockerfile +79 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/worker/GRACEFUL-DEPLOYMENT.md +270 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/worker/src/activities/index.ts +1 -1
- package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/worker/src/activities/reporting.ts +23 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/worker/src/index.ts +70 -5
- package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/worker/src/outbox-poller.ts +246 -90
- package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/worker/src/tests/cron-trigger-workflow.test.ts +20 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/worker/src/workflows/dsl-workflow.ts +96 -8
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/nginx.conf +74 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/components/agentPanel/ElementInspector.tsx +18 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/components/agentPanel/promptGenerator.ts +1 -1
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/helpers/form.tsx +1 -1
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/locales/en/common.json +3 -3
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/locales/zh_CN/common.json +3 -3
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/plugins/devShellPlugin.ts +4 -1
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/canvas/CanvasEditPage.tsx +9 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/canvas/CanvasListPage.tsx +156 -139
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/canvas/CanvasPage.tsx +14 -2
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/canvas/CanvasToolbar.tsx +62 -0
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/canvas/PublishModal.tsx +4 -6
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/canvas/canvasApi.ts +18 -27
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/canvas/canvasDefaults.ts +32 -11
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/canvas/demos.ts +48 -61
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/canvas-page/index.tsx +3 -6
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/DslView.tsx +16 -16
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/editor.tsx +28 -35
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/instance-detail.tsx +34 -3
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/show.tsx +1 -1
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/types.ts +1 -1
- package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/styles/antd.css +6 -0
- package/package.json +1 -1
- package/dist/lib/application/files/gadmin2-game-angle-demo/Dockerfile +0 -63
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/scripts/sync-resources.ts +0 -100
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/seed/permissions.ts +0 -302
- package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/canvas/canvas.controller.spec.ts +0 -20
- package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/sql/create-event-trigger.sql +0 -87
- /package/dist/lib/application/files/gadmin2-game-angle-demo/{GRACEFUL-DEPLOYMENT.md → server/GRACEFUL-DEPLOYMENT.md} +0 -0
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { Test, TestingModule } from '@nestjs/testing';
|
|
2
|
+
import { ScheduleNotFoundError } from '@temporalio/client';
|
|
3
|
+
import { TemporalService } from './temporal.service';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Mock the @temporalio/client module so we can intercept schedule / workflow ops
|
|
7
|
+
* without needing a running Temporal Server.
|
|
8
|
+
*/
|
|
9
|
+
const mockHandle = {
|
|
10
|
+
describe: jest.fn(),
|
|
11
|
+
update: jest.fn(),
|
|
12
|
+
pause: jest.fn(),
|
|
13
|
+
unpause: jest.fn(),
|
|
14
|
+
delete: jest.fn(),
|
|
15
|
+
};
|
|
16
|
+
const mockClient = {
|
|
17
|
+
schedule: {
|
|
18
|
+
getHandle: jest.fn(() => mockHandle),
|
|
19
|
+
create: jest.fn(),
|
|
20
|
+
},
|
|
21
|
+
workflow: {
|
|
22
|
+
start: jest.fn(),
|
|
23
|
+
getHandle: jest.fn(() => ({ cancel: jest.fn() })),
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
jest.mock('@temporalio/client', () => {
|
|
28
|
+
class FakeScheduleNotFoundError extends Error {
|
|
29
|
+
constructor(msg = 'not found') {
|
|
30
|
+
super(msg);
|
|
31
|
+
this.name = 'ScheduleNotFoundError';
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return {
|
|
35
|
+
Client: jest.fn(() => mockClient),
|
|
36
|
+
Connection: { connect: jest.fn(async () => ({ close: jest.fn() })) },
|
|
37
|
+
ScheduleNotFoundError: FakeScheduleNotFoundError,
|
|
38
|
+
};
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe('TemporalService — cron schedule methods', () => {
|
|
42
|
+
let service: TemporalService;
|
|
43
|
+
|
|
44
|
+
beforeEach(async () => {
|
|
45
|
+
jest.clearAllMocks();
|
|
46
|
+
const module: TestingModule = await Test.createTestingModule({
|
|
47
|
+
providers: [TemporalService],
|
|
48
|
+
}).compile();
|
|
49
|
+
service = module.get<TemporalService>(TemporalService);
|
|
50
|
+
await service.onModuleInit();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe('upsertCronSchedule', () => {
|
|
54
|
+
it('creates a new schedule when none exists', async () => {
|
|
55
|
+
mockHandle.describe.mockRejectedValueOnce(
|
|
56
|
+
new ScheduleNotFoundError('nf', 'wf-x-cron'),
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
await service.upsertCronSchedule({
|
|
60
|
+
workflowId: 42,
|
|
61
|
+
versionId: 7,
|
|
62
|
+
dsl: { nodes: [], edges: [] },
|
|
63
|
+
cron: '0 * * * *',
|
|
64
|
+
timezone: 'Asia/Shanghai',
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
expect(mockClient.schedule.create).toHaveBeenCalledTimes(1);
|
|
68
|
+
const arg = mockClient.schedule.create.mock.calls[0][0];
|
|
69
|
+
expect(arg.scheduleId).toBe('wf-42-cron');
|
|
70
|
+
expect(arg.spec.cronExpressions).toEqual(['0 * * * *']);
|
|
71
|
+
expect(arg.spec.timezoneName).toBe('Asia/Shanghai');
|
|
72
|
+
expect(arg.action.workflowType).toBe('cronTriggerWorkflow');
|
|
73
|
+
expect(arg.action.args[0]).toMatchObject({
|
|
74
|
+
workflowId: 42,
|
|
75
|
+
versionId: 7,
|
|
76
|
+
scheduleId: 'wf-42-cron',
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('updates an existing schedule', async () => {
|
|
81
|
+
mockHandle.describe.mockResolvedValueOnce({});
|
|
82
|
+
|
|
83
|
+
await service.upsertCronSchedule({
|
|
84
|
+
workflowId: 42,
|
|
85
|
+
versionId: 8,
|
|
86
|
+
dsl: { nodes: [], edges: [] },
|
|
87
|
+
cron: '*/5 * * * *',
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
expect(mockClient.schedule.create).not.toHaveBeenCalled();
|
|
91
|
+
expect(mockHandle.update).toHaveBeenCalledTimes(1);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('omits timezoneName when timezone is not provided', async () => {
|
|
95
|
+
mockHandle.describe.mockRejectedValueOnce(
|
|
96
|
+
new ScheduleNotFoundError('nf', 'wf-x-cron'),
|
|
97
|
+
);
|
|
98
|
+
await service.upsertCronSchedule({
|
|
99
|
+
workflowId: 1,
|
|
100
|
+
versionId: 1,
|
|
101
|
+
dsl: {},
|
|
102
|
+
cron: '0 0 * * *',
|
|
103
|
+
});
|
|
104
|
+
const spec = mockClient.schedule.create.mock.calls[0][0].spec;
|
|
105
|
+
expect(spec.timezoneName).toBeUndefined();
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe('pauseCronSchedule', () => {
|
|
110
|
+
it('calls handle.pause for existing schedule', async () => {
|
|
111
|
+
mockHandle.pause.mockResolvedValueOnce(undefined);
|
|
112
|
+
await service.pauseCronSchedule(42);
|
|
113
|
+
expect(mockHandle.pause).toHaveBeenCalled();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('is a no-op when schedule does not exist', async () => {
|
|
117
|
+
mockHandle.pause.mockRejectedValueOnce(
|
|
118
|
+
new ScheduleNotFoundError('nf', 'wf-x-cron'),
|
|
119
|
+
);
|
|
120
|
+
await expect(service.pauseCronSchedule(42)).resolves.toBeUndefined();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('rethrows non-NotFound errors', async () => {
|
|
124
|
+
mockHandle.pause.mockRejectedValueOnce(new Error('boom'));
|
|
125
|
+
await expect(service.pauseCronSchedule(42)).rejects.toThrow('boom');
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe('unpauseCronSchedule', () => {
|
|
130
|
+
it('calls handle.unpause', async () => {
|
|
131
|
+
mockHandle.unpause.mockResolvedValueOnce(undefined);
|
|
132
|
+
await service.unpauseCronSchedule(42);
|
|
133
|
+
expect(mockHandle.unpause).toHaveBeenCalled();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('swallows ScheduleNotFoundError', async () => {
|
|
137
|
+
mockHandle.unpause.mockRejectedValueOnce(
|
|
138
|
+
new ScheduleNotFoundError('nf', 'wf-x-cron'),
|
|
139
|
+
);
|
|
140
|
+
await expect(service.unpauseCronSchedule(42)).resolves.toBeUndefined();
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe('deleteCronSchedule', () => {
|
|
145
|
+
it('calls handle.delete', async () => {
|
|
146
|
+
mockHandle.delete.mockResolvedValueOnce(undefined);
|
|
147
|
+
await service.deleteCronSchedule(42);
|
|
148
|
+
expect(mockHandle.delete).toHaveBeenCalled();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('swallows ScheduleNotFoundError', async () => {
|
|
152
|
+
mockHandle.delete.mockRejectedValueOnce(
|
|
153
|
+
new ScheduleNotFoundError('nf', 'wf-x-cron'),
|
|
154
|
+
);
|
|
155
|
+
await expect(service.deleteCronSchedule(42)).resolves.toBeUndefined();
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
});
|
|
@@ -4,12 +4,14 @@ import {
|
|
|
4
4
|
OnModuleDestroy,
|
|
5
5
|
OnModuleInit,
|
|
6
6
|
} from '@nestjs/common';
|
|
7
|
-
import { Client, Connection } from '@temporalio/client';
|
|
7
|
+
import { Client, Connection, ScheduleNotFoundError } from '@temporalio/client';
|
|
8
8
|
|
|
9
9
|
const TEMPORAL_ADDRESS = process.env.TEMPORAL_ADDRESS || 'localhost:7233';
|
|
10
10
|
const TEMPORAL_NAMESPACE = process.env.TEMPORAL_NAMESPACE || 'default';
|
|
11
11
|
const TASK_QUEUE = process.env.TEMPORAL_TASK_QUEUE || 'workflow-execution';
|
|
12
12
|
|
|
13
|
+
const cronScheduleId = (workflowId: number) => `wf-${workflowId}-cron`;
|
|
14
|
+
|
|
13
15
|
@Injectable()
|
|
14
16
|
export class TemporalService implements OnModuleInit, OnModuleDestroy {
|
|
15
17
|
private connection: Connection;
|
|
@@ -97,4 +99,111 @@ export class TemporalService implements OnModuleInit, OnModuleDestroy {
|
|
|
97
99
|
isAvailable(): boolean {
|
|
98
100
|
return !!this.client;
|
|
99
101
|
}
|
|
102
|
+
|
|
103
|
+
// ─── Cron Schedules ────────────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Create or update a Temporal Schedule that periodically starts cronTriggerWorkflow.
|
|
107
|
+
* Idempotent — safe to call on every publish.
|
|
108
|
+
*/
|
|
109
|
+
async upsertCronSchedule(input: {
|
|
110
|
+
workflowId: number;
|
|
111
|
+
versionId: number;
|
|
112
|
+
dsl: any;
|
|
113
|
+
cron: string;
|
|
114
|
+
timezone?: string;
|
|
115
|
+
}): Promise<void> {
|
|
116
|
+
if (!this.client) throw new Error('Temporal client not connected');
|
|
117
|
+
|
|
118
|
+
const scheduleId = cronScheduleId(input.workflowId);
|
|
119
|
+
const action = {
|
|
120
|
+
type: 'startWorkflow' as const,
|
|
121
|
+
workflowType: 'cronTriggerWorkflow',
|
|
122
|
+
taskQueue: TASK_QUEUE,
|
|
123
|
+
args: [
|
|
124
|
+
{
|
|
125
|
+
workflowId: input.workflowId,
|
|
126
|
+
versionId: input.versionId,
|
|
127
|
+
dsl: input.dsl,
|
|
128
|
+
scheduleId,
|
|
129
|
+
},
|
|
130
|
+
],
|
|
131
|
+
};
|
|
132
|
+
const spec = {
|
|
133
|
+
cronExpressions: [input.cron],
|
|
134
|
+
...(input.timezone ? { timezoneName: input.timezone } : {}),
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const handle = this.client.schedule.getHandle(scheduleId);
|
|
138
|
+
try {
|
|
139
|
+
await handle.describe();
|
|
140
|
+
// Exists — update spec + action so DSL/version changes propagate
|
|
141
|
+
await handle.update((prev) => ({
|
|
142
|
+
...prev,
|
|
143
|
+
spec,
|
|
144
|
+
action,
|
|
145
|
+
}));
|
|
146
|
+
this.logger.log(
|
|
147
|
+
`Updated cron schedule ${scheduleId} (cron="${input.cron}")`,
|
|
148
|
+
);
|
|
149
|
+
} catch (err: any) {
|
|
150
|
+
if (err instanceof ScheduleNotFoundError) {
|
|
151
|
+
await this.client.schedule.create({
|
|
152
|
+
scheduleId,
|
|
153
|
+
spec,
|
|
154
|
+
action,
|
|
155
|
+
});
|
|
156
|
+
this.logger.log(
|
|
157
|
+
`Created cron schedule ${scheduleId} (cron="${input.cron}")`,
|
|
158
|
+
);
|
|
159
|
+
} else {
|
|
160
|
+
throw err;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Pause a cron schedule. No-op if it doesn't exist.
|
|
167
|
+
*/
|
|
168
|
+
async pauseCronSchedule(workflowId: number): Promise<void> {
|
|
169
|
+
if (!this.client) return;
|
|
170
|
+
const scheduleId = cronScheduleId(workflowId);
|
|
171
|
+
try {
|
|
172
|
+
await this.client.schedule.getHandle(scheduleId).pause();
|
|
173
|
+
this.logger.log(`Paused cron schedule ${scheduleId}`);
|
|
174
|
+
} catch (err: any) {
|
|
175
|
+
if (err instanceof ScheduleNotFoundError) return;
|
|
176
|
+
throw err;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Resume a paused cron schedule. No-op if it doesn't exist.
|
|
182
|
+
*/
|
|
183
|
+
async unpauseCronSchedule(workflowId: number): Promise<void> {
|
|
184
|
+
if (!this.client) return;
|
|
185
|
+
const scheduleId = cronScheduleId(workflowId);
|
|
186
|
+
try {
|
|
187
|
+
await this.client.schedule.getHandle(scheduleId).unpause();
|
|
188
|
+
this.logger.log(`Unpaused cron schedule ${scheduleId}`);
|
|
189
|
+
} catch (err: any) {
|
|
190
|
+
if (err instanceof ScheduleNotFoundError) return;
|
|
191
|
+
throw err;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Delete a cron schedule. No-op if it doesn't exist.
|
|
197
|
+
*/
|
|
198
|
+
async deleteCronSchedule(workflowId: number): Promise<void> {
|
|
199
|
+
if (!this.client) return;
|
|
200
|
+
const scheduleId = cronScheduleId(workflowId);
|
|
201
|
+
try {
|
|
202
|
+
await this.client.schedule.getHandle(scheduleId).delete();
|
|
203
|
+
this.logger.log(`Deleted cron schedule ${scheduleId}`);
|
|
204
|
+
} catch (err: any) {
|
|
205
|
+
if (err instanceof ScheduleNotFoundError) return;
|
|
206
|
+
throw err;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
100
209
|
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import {
|
|
2
|
+
signWebhookPayload,
|
|
3
|
+
verifyWebhookSignature,
|
|
4
|
+
} from './webhook-signature.util';
|
|
5
|
+
|
|
6
|
+
describe('webhook-signature.util', () => {
|
|
7
|
+
const secret = 'super-secret';
|
|
8
|
+
const ts = '1700000000';
|
|
9
|
+
const body = '{"orderId":42}';
|
|
10
|
+
|
|
11
|
+
describe('verifyWebhookSignature', () => {
|
|
12
|
+
it('returns true for a valid signature', () => {
|
|
13
|
+
const sig = signWebhookPayload(secret, ts, body);
|
|
14
|
+
expect(verifyWebhookSignature(secret, ts, body, sig)).toBe(true);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('returns false when secret is wrong', () => {
|
|
18
|
+
const sig = signWebhookPayload(secret, ts, body);
|
|
19
|
+
expect(verifyWebhookSignature('different', ts, body, sig)).toBe(false);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('returns false when body is tampered', () => {
|
|
23
|
+
const sig = signWebhookPayload(secret, ts, body);
|
|
24
|
+
expect(verifyWebhookSignature(secret, ts, '{"orderId":99}', sig)).toBe(
|
|
25
|
+
false,
|
|
26
|
+
);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('returns false when timestamp differs', () => {
|
|
30
|
+
const sig = signWebhookPayload(secret, ts, body);
|
|
31
|
+
expect(verifyWebhookSignature(secret, '1700000099', body, sig)).toBe(
|
|
32
|
+
false,
|
|
33
|
+
);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('returns false on missing signature header', () => {
|
|
37
|
+
expect(verifyWebhookSignature(secret, ts, body, undefined)).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('returns false on missing secret', () => {
|
|
41
|
+
const sig = signWebhookPayload(secret, ts, body);
|
|
42
|
+
expect(verifyWebhookSignature('', ts, body, sig)).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('returns false (no throw) on length-mismatched signature', () => {
|
|
46
|
+
expect(() =>
|
|
47
|
+
verifyWebhookSignature(secret, ts, body, 'sha256=short'),
|
|
48
|
+
).not.toThrow();
|
|
49
|
+
expect(verifyWebhookSignature(secret, ts, body, 'sha256=short')).toBe(
|
|
50
|
+
false,
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('accepts Buffer body input', () => {
|
|
55
|
+
const buf = Buffer.from(body);
|
|
56
|
+
const sig = signWebhookPayload(secret, ts, buf);
|
|
57
|
+
expect(verifyWebhookSignature(secret, ts, buf, sig)).toBe(true);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('produces different signatures for different timestamps (replay protection)', () => {
|
|
61
|
+
const sig1 = signWebhookPayload(secret, '1700000000', body);
|
|
62
|
+
const sig2 = signWebhookPayload(secret, '1700000001', body);
|
|
63
|
+
expect(sig1).not.toBe(sig2);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('signWebhookPayload', () => {
|
|
68
|
+
it('produces a sha256= prefixed hex digest', () => {
|
|
69
|
+
const sig = signWebhookPayload(secret, ts, body);
|
|
70
|
+
expect(sig).toMatch(/^sha256=[a-f0-9]{64}$/);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('is deterministic for same inputs', () => {
|
|
74
|
+
expect(signWebhookPayload(secret, ts, body)).toBe(
|
|
75
|
+
signWebhookPayload(secret, ts, body),
|
|
76
|
+
);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import * as crypto from 'crypto';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Verify an HMAC-SHA256 signature for a webhook request.
|
|
5
|
+
*
|
|
6
|
+
* Signing payload format: `${timestamp}.${rawBody}` — the timestamp prefix prevents
|
|
7
|
+
* replay attacks even if the body is captured.
|
|
8
|
+
*
|
|
9
|
+
* Header format: `X-Workflow-Signature: sha256=<hex digest>`
|
|
10
|
+
*
|
|
11
|
+
* Uses crypto.timingSafeEqual to prevent timing attacks.
|
|
12
|
+
* Returns false (not throw) on length mismatch or any other comparison failure.
|
|
13
|
+
*/
|
|
14
|
+
export function verifyWebhookSignature(
|
|
15
|
+
secret: string,
|
|
16
|
+
timestamp: string,
|
|
17
|
+
rawBody: Buffer | string,
|
|
18
|
+
signatureHeader: string | undefined,
|
|
19
|
+
): boolean {
|
|
20
|
+
if (!signatureHeader || !secret || !timestamp) return false;
|
|
21
|
+
|
|
22
|
+
const body = typeof rawBody === 'string' ? Buffer.from(rawBody) : rawBody;
|
|
23
|
+
const signingPayload = Buffer.concat([Buffer.from(`${timestamp}.`), body]);
|
|
24
|
+
const expected = `sha256=${crypto
|
|
25
|
+
.createHmac('sha256', secret)
|
|
26
|
+
.update(signingPayload)
|
|
27
|
+
.digest('hex')}`;
|
|
28
|
+
|
|
29
|
+
const expectedBuf = Buffer.from(expected);
|
|
30
|
+
const actualBuf = Buffer.from(signatureHeader);
|
|
31
|
+
if (expectedBuf.length !== actualBuf.length) return false;
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
return crypto.timingSafeEqual(expectedBuf, actualBuf);
|
|
35
|
+
} catch {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Helper for tests / manual signing — returns the X-Workflow-Signature header value.
|
|
42
|
+
*/
|
|
43
|
+
export function signWebhookPayload(
|
|
44
|
+
secret: string,
|
|
45
|
+
timestamp: string,
|
|
46
|
+
rawBody: Buffer | string,
|
|
47
|
+
): string {
|
|
48
|
+
const body = typeof rawBody === 'string' ? Buffer.from(rawBody) : rawBody;
|
|
49
|
+
const signingPayload = Buffer.concat([Buffer.from(`${timestamp}.`), body]);
|
|
50
|
+
return `sha256=${crypto
|
|
51
|
+
.createHmac('sha256', secret)
|
|
52
|
+
.update(signingPayload)
|
|
53
|
+
.digest('hex')}`;
|
|
54
|
+
}
|
|
@@ -3,16 +3,20 @@ import {
|
|
|
3
3
|
Controller,
|
|
4
4
|
Delete,
|
|
5
5
|
Get,
|
|
6
|
+
Headers,
|
|
7
|
+
HttpCode,
|
|
6
8
|
Param,
|
|
7
9
|
ParseIntPipe,
|
|
8
10
|
Patch,
|
|
9
11
|
Post,
|
|
10
12
|
Query,
|
|
13
|
+
RawBodyRequest,
|
|
11
14
|
Req,
|
|
12
15
|
UnauthorizedException,
|
|
13
16
|
UseGuards,
|
|
14
17
|
} from '@nestjs/common';
|
|
15
18
|
import { ApiTags } from '@nestjs/swagger';
|
|
19
|
+
import { Request } from 'express';
|
|
16
20
|
import AuthGuard, { AllowUnauthorizedRequest } from '../../lib/auth.guard';
|
|
17
21
|
import { TemporalService } from './temporal.service';
|
|
18
22
|
import {
|
|
@@ -217,4 +221,34 @@ export class WorkflowController {
|
|
|
217
221
|
this.temporalService,
|
|
218
222
|
);
|
|
219
223
|
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Public webhook entry point.
|
|
227
|
+
* Triggers any published workflow with a webhook_trigger node matching (path, method).
|
|
228
|
+
* Caller must provide HMAC-SHA256 signature in X-Workflow-Signature header.
|
|
229
|
+
*
|
|
230
|
+
* Returns 202 Accepted with the list of created instance IDs (workflow runs async).
|
|
231
|
+
*/
|
|
232
|
+
@Post('webhook/:path')
|
|
233
|
+
@HttpCode(202)
|
|
234
|
+
@AllowUnauthorizedRequest()
|
|
235
|
+
async receiveWebhook(
|
|
236
|
+
@Param('path') path: string,
|
|
237
|
+
@Headers('x-workflow-signature') signature: string,
|
|
238
|
+
@Headers('x-workflow-timestamp') timestamp: string,
|
|
239
|
+
@Req() req: RawBodyRequest<Request>,
|
|
240
|
+
@Body() body: any,
|
|
241
|
+
@Query() query: any,
|
|
242
|
+
): Promise<{ instanceIds: string[] }> {
|
|
243
|
+
return this.workflowService.receiveWebhook({
|
|
244
|
+
path,
|
|
245
|
+
method: req.method,
|
|
246
|
+
signatureHeader: signature,
|
|
247
|
+
timestampHeader: timestamp,
|
|
248
|
+
rawBody: req.rawBody ?? Buffer.from(''),
|
|
249
|
+
body,
|
|
250
|
+
headers: req.headers as Record<string, any>,
|
|
251
|
+
query,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
220
254
|
}
|