@orthacms/webhooks-server 0.5.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/LICENSE +21 -0
- package/README.md +7 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +33 -0
- package/dist/lib/application/dto/list-deliveries-query.dto.d.ts +8 -0
- package/dist/lib/application/dto/list-deliveries-query.dto.d.ts.map +1 -0
- package/dist/lib/application/dto/list-deliveries-query.dto.js +64 -0
- package/dist/lib/application/dto/save-webhook-endpoint.dto.d.ts +23 -0
- package/dist/lib/application/dto/save-webhook-endpoint.dto.d.ts.map +1 -0
- package/dist/lib/application/dto/save-webhook-endpoint.dto.js +127 -0
- package/dist/lib/application/webhook-endpoints.service.d.ts +82 -0
- package/dist/lib/application/webhook-endpoints.service.d.ts.map +1 -0
- package/dist/lib/application/webhook-endpoints.service.js +213 -0
- package/dist/lib/docs/describe-webhooks-api.d.ts +16 -0
- package/dist/lib/docs/describe-webhooks-api.d.ts.map +1 -0
- package/dist/lib/docs/describe-webhooks-api.js +170 -0
- package/dist/lib/docs/webhook-schemas.d.ts +21 -0
- package/dist/lib/docs/webhook-schemas.d.ts.map +1 -0
- package/dist/lib/docs/webhook-schemas.js +360 -0
- package/dist/lib/domain/errors/index.d.ts +3 -0
- package/dist/lib/domain/errors/index.d.ts.map +1 -0
- package/dist/lib/domain/errors/index.js +7 -0
- package/dist/lib/domain/errors/webhook-delivery-not-found.error.d.ts +12 -0
- package/dist/lib/domain/errors/webhook-delivery-not-found.error.d.ts.map +1 -0
- package/dist/lib/domain/errors/webhook-delivery-not-found.error.js +19 -0
- package/dist/lib/domain/errors/webhook-endpoint-not-found.error.d.ts +6 -0
- package/dist/lib/domain/errors/webhook-endpoint-not-found.error.d.ts.map +1 -0
- package/dist/lib/domain/errors/webhook-endpoint-not-found.error.js +13 -0
- package/dist/lib/domain/webhook-secret.d.ts +29 -0
- package/dist/lib/domain/webhook-secret.d.ts.map +1 -0
- package/dist/lib/domain/webhook-secret.js +34 -0
- package/dist/lib/domain/webhook-views.d.ts +85 -0
- package/dist/lib/domain/webhook-views.d.ts.map +1 -0
- package/dist/lib/domain/webhook-views.js +2 -0
- package/dist/lib/http/controllers/webhook-deliveries.controller.d.ts +28 -0
- package/dist/lib/http/controllers/webhook-deliveries.controller.d.ts.map +1 -0
- package/dist/lib/http/controllers/webhook-deliveries.controller.js +110 -0
- package/dist/lib/http/controllers/webhook-endpoints.controller.d.ts +61 -0
- package/dist/lib/http/controllers/webhook-endpoints.controller.d.ts.map +1 -0
- package/dist/lib/http/controllers/webhook-endpoints.controller.js +213 -0
- package/dist/lib/http/controllers/webhook-events.controller.d.ts +13 -0
- package/dist/lib/http/controllers/webhook-events.controller.d.ts.map +1 -0
- package/dist/lib/http/controllers/webhook-events.controller.js +38 -0
- package/dist/lib/infrastructure/event-mapping.d.ts +13 -0
- package/dist/lib/infrastructure/event-mapping.d.ts.map +1 -0
- package/dist/lib/infrastructure/event-mapping.js +69 -0
- package/dist/lib/infrastructure/purge/webhook-workspaces.purger.d.ts +42 -0
- package/dist/lib/infrastructure/purge/webhook-workspaces.purger.d.ts.map +1 -0
- package/dist/lib/infrastructure/purge/webhook-workspaces.purger.js +66 -0
- package/dist/lib/infrastructure/schema/index.d.ts +3 -0
- package/dist/lib/infrastructure/schema/index.d.ts.map +1 -0
- package/dist/lib/infrastructure/schema/index.js +8 -0
- package/dist/lib/infrastructure/schema/webhook-deliveries.d.ts +332 -0
- package/dist/lib/infrastructure/schema/webhook-deliveries.d.ts.map +1 -0
- package/dist/lib/infrastructure/schema/webhook-deliveries.js +92 -0
- package/dist/lib/infrastructure/schema/webhook-endpoints.d.ts +330 -0
- package/dist/lib/infrastructure/schema/webhook-endpoints.d.ts.map +1 -0
- package/dist/lib/infrastructure/schema/webhook-endpoints.js +94 -0
- package/dist/lib/infrastructure/webhook-delivery.repository.d.ts +99 -0
- package/dist/lib/infrastructure/webhook-delivery.repository.d.ts.map +1 -0
- package/dist/lib/infrastructure/webhook-delivery.repository.js +255 -0
- package/dist/lib/infrastructure/webhook-delivery.worker.d.ts +72 -0
- package/dist/lib/infrastructure/webhook-delivery.worker.d.ts.map +1 -0
- package/dist/lib/infrastructure/webhook-delivery.worker.js +216 -0
- package/dist/lib/infrastructure/webhook-endpoint.repository.d.ts +88 -0
- package/dist/lib/infrastructure/webhook-endpoint.repository.d.ts.map +1 -0
- package/dist/lib/infrastructure/webhook-endpoint.repository.js +306 -0
- package/dist/lib/infrastructure/webhook-fanout.subscriber.d.ts +36 -0
- package/dist/lib/infrastructure/webhook-fanout.subscriber.d.ts.map +1 -0
- package/dist/lib/infrastructure/webhook-fanout.subscriber.js +83 -0
- package/dist/lib/infrastructure/webhook-http.client.d.ts +76 -0
- package/dist/lib/infrastructure/webhook-http.client.d.ts.map +1 -0
- package/dist/lib/infrastructure/webhook-http.client.js +225 -0
- package/dist/lib/types/webhooks-config.d.ts +64 -0
- package/dist/lib/types/webhooks-config.d.ts.map +1 -0
- package/dist/lib/types/webhooks-config.js +24 -0
- package/dist/lib/utils/webhooks-plugin.d.ts +35 -0
- package/dist/lib/utils/webhooks-plugin.d.ts.map +1 -0
- package/dist/lib/utils/webhooks-plugin.js +90 -0
- package/dist/lib/webhooks.module.d.ts +18 -0
- package/dist/lib/webhooks.module.d.ts.map +1 -0
- package/dist/lib/webhooks.module.js +72 -0
- package/dist/lib/webhooks.tokens.d.ts +5 -0
- package/dist/lib/webhooks.tokens.d.ts.map +1 -0
- package/dist/lib/webhooks.tokens.js +9 -0
- package/migrations/0000_init_webhooks.sql +53 -0
- package/migrations/meta/0000_snapshot.json +424 -0
- package/migrations/meta/_journal.json +13 -0
- package/package.json +45 -0
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.WebhookEndpointRepository = void 0;
|
|
4
|
+
const tslib_1 = require("tslib");
|
|
5
|
+
const common_1 = require("@nestjs/common");
|
|
6
|
+
const database_1 = require("@orthacms/database");
|
|
7
|
+
const drizzle_orm_1 = require("drizzle-orm");
|
|
8
|
+
const webhook_secret_1 = require("../domain/webhook-secret");
|
|
9
|
+
const webhook_deliveries_1 = require("./schema/webhook-deliveries");
|
|
10
|
+
const webhook_endpoints_1 = require("./schema/webhook-endpoints");
|
|
11
|
+
/** Reads and writes over the two endpoint tables. */
|
|
12
|
+
let WebhookEndpointRepository = class WebhookEndpointRepository {
|
|
13
|
+
db;
|
|
14
|
+
constructor(db) {
|
|
15
|
+
this.db = db;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Every enabled endpoint with its workspace set, for fan-out.
|
|
19
|
+
*
|
|
20
|
+
* One query with an aggregated sub-select rather than a join: a join would
|
|
21
|
+
* return one row per workspace and force the caller to regroup, and this
|
|
22
|
+
* runs on every content write.
|
|
23
|
+
*/
|
|
24
|
+
async listSubscriptions() {
|
|
25
|
+
const rows = await this.db
|
|
26
|
+
.select({
|
|
27
|
+
id: webhook_endpoints_1.webhookEndpoints.id,
|
|
28
|
+
enabled: webhook_endpoints_1.webhookEndpoints.enabled,
|
|
29
|
+
allWorkspaces: webhook_endpoints_1.webhookEndpoints.allWorkspaces,
|
|
30
|
+
eventKinds: webhook_endpoints_1.webhookEndpoints.eventKinds,
|
|
31
|
+
contentTypes: webhook_endpoints_1.webhookEndpoints.contentTypes,
|
|
32
|
+
workspaceIds: (0, drizzle_orm_1.sql) `coalesce((select array_agg(${webhook_endpoints_1.webhookEndpointWorkspaces.workspaceId}) from ${webhook_endpoints_1.webhookEndpointWorkspaces} where ${webhook_endpoints_1.webhookEndpointWorkspaces.endpointId} = ${webhook_endpoints_1.webhookEndpoints.id}), '{}')`
|
|
33
|
+
})
|
|
34
|
+
.from(webhook_endpoints_1.webhookEndpoints)
|
|
35
|
+
.where((0, drizzle_orm_1.eq)(webhook_endpoints_1.webhookEndpoints.enabled, true));
|
|
36
|
+
return rows.map((row) => ({
|
|
37
|
+
id: row.id,
|
|
38
|
+
enabled: row.enabled,
|
|
39
|
+
allWorkspaces: row.allWorkspaces,
|
|
40
|
+
workspaceIds: row.workspaceIds ?? [],
|
|
41
|
+
eventKinds: asStringArray(row.eventKinds),
|
|
42
|
+
contentTypes: asStringArray(row.contentTypes)
|
|
43
|
+
}));
|
|
44
|
+
}
|
|
45
|
+
/** The endpoint the worker is about to POST to, or null when it is gone. */
|
|
46
|
+
async findForDelivery(id) {
|
|
47
|
+
const [row] = await this.db
|
|
48
|
+
.select({
|
|
49
|
+
id: webhook_endpoints_1.webhookEndpoints.id,
|
|
50
|
+
name: webhook_endpoints_1.webhookEndpoints.name,
|
|
51
|
+
url: webhook_endpoints_1.webhookEndpoints.url,
|
|
52
|
+
secret: webhook_endpoints_1.webhookEndpoints.secret,
|
|
53
|
+
headers: webhook_endpoints_1.webhookEndpoints.headers,
|
|
54
|
+
enabled: webhook_endpoints_1.webhookEndpoints.enabled
|
|
55
|
+
})
|
|
56
|
+
.from(webhook_endpoints_1.webhookEndpoints)
|
|
57
|
+
.where((0, drizzle_orm_1.eq)(webhook_endpoints_1.webhookEndpoints.id, id))
|
|
58
|
+
.limit(1);
|
|
59
|
+
if (!row)
|
|
60
|
+
return null;
|
|
61
|
+
return { ...row, headers: asHeaderMap(row.headers) };
|
|
62
|
+
}
|
|
63
|
+
/** Every endpoint, newest first, each with its last delivery. */
|
|
64
|
+
async list() {
|
|
65
|
+
const rows = await this.db
|
|
66
|
+
.select()
|
|
67
|
+
.from(webhook_endpoints_1.webhookEndpoints)
|
|
68
|
+
.orderBy((0, drizzle_orm_1.desc)(webhook_endpoints_1.webhookEndpoints.createdAt));
|
|
69
|
+
if (rows.length === 0)
|
|
70
|
+
return [];
|
|
71
|
+
const ids = rows.map((row) => row.id);
|
|
72
|
+
const [workspaces, lastDeliveries] = await Promise.all([
|
|
73
|
+
this.workspacesFor(ids),
|
|
74
|
+
this.lastDeliveriesFor(ids)
|
|
75
|
+
]);
|
|
76
|
+
return rows.map((row) => toView(row, workspaces.get(row.id) ?? [], lastDeliveries.get(row.id) ?? null));
|
|
77
|
+
}
|
|
78
|
+
/** One endpoint, or null. */
|
|
79
|
+
async findById(id) {
|
|
80
|
+
const [row] = await this.db
|
|
81
|
+
.select()
|
|
82
|
+
.from(webhook_endpoints_1.webhookEndpoints)
|
|
83
|
+
.where((0, drizzle_orm_1.eq)(webhook_endpoints_1.webhookEndpoints.id, id))
|
|
84
|
+
.limit(1);
|
|
85
|
+
if (!row)
|
|
86
|
+
return null;
|
|
87
|
+
const [workspaces, lastDeliveries] = await Promise.all([
|
|
88
|
+
this.workspacesFor([id]),
|
|
89
|
+
this.lastDeliveriesFor([id])
|
|
90
|
+
]);
|
|
91
|
+
return toView(row, workspaces.get(id) ?? [], lastDeliveries.get(id) ?? null);
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Creates an endpoint and its workspace set in one transaction, so an
|
|
95
|
+
* endpoint can never exist with half its subscription.
|
|
96
|
+
*/
|
|
97
|
+
async create(model, secret, createdBy) {
|
|
98
|
+
return this.db.transaction(async (tx) => {
|
|
99
|
+
const [row] = await tx
|
|
100
|
+
.insert(webhook_endpoints_1.webhookEndpoints)
|
|
101
|
+
.values({
|
|
102
|
+
name: model.name,
|
|
103
|
+
url: model.url,
|
|
104
|
+
secret,
|
|
105
|
+
secretHint: (0, webhook_secret_1.secretHint)(secret),
|
|
106
|
+
enabled: model.enabled,
|
|
107
|
+
eventKinds: model.eventKinds,
|
|
108
|
+
contentTypes: model.contentTypes,
|
|
109
|
+
allWorkspaces: model.allWorkspaces,
|
|
110
|
+
headers: model.headers,
|
|
111
|
+
createdBy
|
|
112
|
+
})
|
|
113
|
+
.returning({ id: webhook_endpoints_1.webhookEndpoints.id });
|
|
114
|
+
await this.replaceWorkspaces(tx, row.id, model);
|
|
115
|
+
return row.id;
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
/** Applies a full write model to an existing endpoint. */
|
|
119
|
+
async update(id, model) {
|
|
120
|
+
await this.db.transaction(async (tx) => {
|
|
121
|
+
await tx
|
|
122
|
+
.update(webhook_endpoints_1.webhookEndpoints)
|
|
123
|
+
.set({
|
|
124
|
+
name: model.name,
|
|
125
|
+
url: model.url,
|
|
126
|
+
enabled: model.enabled,
|
|
127
|
+
eventKinds: model.eventKinds,
|
|
128
|
+
contentTypes: model.contentTypes,
|
|
129
|
+
allWorkspaces: model.allWorkspaces,
|
|
130
|
+
headers: model.headers,
|
|
131
|
+
// Re-enabling by hand clears the auto-disable state, so the
|
|
132
|
+
// reason shown in the UI can never outlive the condition.
|
|
133
|
+
disabledReason: model.enabled ? null : undefined,
|
|
134
|
+
consecutiveFailures: model.enabled ? 0 : undefined,
|
|
135
|
+
updatedAt: new Date()
|
|
136
|
+
})
|
|
137
|
+
.where((0, drizzle_orm_1.eq)(webhook_endpoints_1.webhookEndpoints.id, id));
|
|
138
|
+
await this.replaceWorkspaces(tx, id, model);
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
/** Rotates the signing secret. */
|
|
142
|
+
async replaceSecret(id, secret) {
|
|
143
|
+
await this.db
|
|
144
|
+
.update(webhook_endpoints_1.webhookEndpoints)
|
|
145
|
+
.set({
|
|
146
|
+
secret,
|
|
147
|
+
secretHint: (0, webhook_secret_1.secretHint)(secret),
|
|
148
|
+
updatedAt: new Date()
|
|
149
|
+
})
|
|
150
|
+
.where((0, drizzle_orm_1.eq)(webhook_endpoints_1.webhookEndpoints.id, id));
|
|
151
|
+
}
|
|
152
|
+
/** Deletes the endpoint; its workspaces and deliveries cascade. */
|
|
153
|
+
async delete(id) {
|
|
154
|
+
await this.db
|
|
155
|
+
.delete(webhook_endpoints_1.webhookEndpoints)
|
|
156
|
+
.where((0, drizzle_orm_1.eq)(webhook_endpoints_1.webhookEndpoints.id, id));
|
|
157
|
+
}
|
|
158
|
+
/** Whether an endpoint with this id exists. */
|
|
159
|
+
async exists(id) {
|
|
160
|
+
const [row] = await this.db
|
|
161
|
+
.select({ id: webhook_endpoints_1.webhookEndpoints.id })
|
|
162
|
+
.from(webhook_endpoints_1.webhookEndpoints)
|
|
163
|
+
.where((0, drizzle_orm_1.eq)(webhook_endpoints_1.webhookEndpoints.id, id))
|
|
164
|
+
.limit(1);
|
|
165
|
+
return row !== undefined;
|
|
166
|
+
}
|
|
167
|
+
/** Clears the consecutive-failure count after a delivery succeeded. */
|
|
168
|
+
async recordSuccess(id) {
|
|
169
|
+
await this.db
|
|
170
|
+
.update(webhook_endpoints_1.webhookEndpoints)
|
|
171
|
+
.set({ consecutiveFailures: 0 })
|
|
172
|
+
.where((0, drizzle_orm_1.eq)(webhook_endpoints_1.webhookEndpoints.id, id));
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Counts one dead delivery against the endpoint and switches it off once
|
|
176
|
+
* `threshold` of them have arrived in a row.
|
|
177
|
+
*
|
|
178
|
+
* The increment and the test are one statement so two workers finishing
|
|
179
|
+
* failed deliveries at the same moment cannot both read the old count and
|
|
180
|
+
* both decide not to disable.
|
|
181
|
+
*
|
|
182
|
+
* @returns whether this failure switched the endpoint off.
|
|
183
|
+
*/
|
|
184
|
+
async recordFailure(id, threshold) {
|
|
185
|
+
const reason = `Disabled automatically after ${threshold} consecutive failed deliveries.`;
|
|
186
|
+
const [row] = await this.db
|
|
187
|
+
.update(webhook_endpoints_1.webhookEndpoints)
|
|
188
|
+
.set({
|
|
189
|
+
consecutiveFailures: (0, drizzle_orm_1.sql) `${webhook_endpoints_1.webhookEndpoints.consecutiveFailures} + 1`,
|
|
190
|
+
enabled: (0, drizzle_orm_1.sql) `case when ${webhook_endpoints_1.webhookEndpoints.consecutiveFailures} + 1 >= ${threshold} then false else ${webhook_endpoints_1.webhookEndpoints.enabled} end`,
|
|
191
|
+
disabledReason: (0, drizzle_orm_1.sql) `case when ${webhook_endpoints_1.webhookEndpoints.consecutiveFailures} + 1 >= ${threshold} then ${reason} else ${webhook_endpoints_1.webhookEndpoints.disabledReason} end`
|
|
192
|
+
})
|
|
193
|
+
.where((0, drizzle_orm_1.eq)(webhook_endpoints_1.webhookEndpoints.id, id))
|
|
194
|
+
.returning({ enabled: webhook_endpoints_1.webhookEndpoints.enabled });
|
|
195
|
+
return row !== undefined && !row.enabled;
|
|
196
|
+
}
|
|
197
|
+
/** Replaces an endpoint's workspace rows to match `model`. */
|
|
198
|
+
async replaceWorkspaces(tx, endpointId, model) {
|
|
199
|
+
await tx
|
|
200
|
+
.delete(webhook_endpoints_1.webhookEndpointWorkspaces)
|
|
201
|
+
.where((0, drizzle_orm_1.eq)(webhook_endpoints_1.webhookEndpointWorkspaces.endpointId, endpointId));
|
|
202
|
+
// "All workspaces" is the flag, not a row per workspace: enumerating
|
|
203
|
+
// them would silently stop covering the next workspace created.
|
|
204
|
+
if (model.allWorkspaces || model.workspaceIds.length === 0)
|
|
205
|
+
return;
|
|
206
|
+
await tx.insert(webhook_endpoints_1.webhookEndpointWorkspaces).values(model.workspaceIds.map((workspaceId) => ({
|
|
207
|
+
endpointId,
|
|
208
|
+
workspaceId
|
|
209
|
+
})));
|
|
210
|
+
}
|
|
211
|
+
/** Workspace ids per endpoint, for a set of endpoints. */
|
|
212
|
+
async workspacesFor(endpointIds) {
|
|
213
|
+
const rows = await this.db
|
|
214
|
+
.select()
|
|
215
|
+
.from(webhook_endpoints_1.webhookEndpointWorkspaces)
|
|
216
|
+
.where((0, drizzle_orm_1.inArray)(webhook_endpoints_1.webhookEndpointWorkspaces.endpointId, endpointIds));
|
|
217
|
+
const grouped = new Map();
|
|
218
|
+
for (const row of rows) {
|
|
219
|
+
const list = grouped.get(row.endpointId) ?? [];
|
|
220
|
+
list.push(row.workspaceId);
|
|
221
|
+
grouped.set(row.endpointId, list);
|
|
222
|
+
}
|
|
223
|
+
return grouped;
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* The most recent delivery per endpoint.
|
|
227
|
+
*
|
|
228
|
+
* `DISTINCT ON` rather than a window function or N queries: one index scan
|
|
229
|
+
* over `(endpoint_id, created_at)` answers the whole list page.
|
|
230
|
+
*/
|
|
231
|
+
async lastDeliveriesFor(endpointIds) {
|
|
232
|
+
const rows = await this.db
|
|
233
|
+
.selectDistinctOn([webhook_deliveries_1.webhookDeliveries.endpointId], {
|
|
234
|
+
id: webhook_deliveries_1.webhookDeliveries.id,
|
|
235
|
+
endpointId: webhook_deliveries_1.webhookDeliveries.endpointId,
|
|
236
|
+
status: webhook_deliveries_1.webhookDeliveries.status,
|
|
237
|
+
eventKind: webhook_deliveries_1.webhookDeliveries.eventKind,
|
|
238
|
+
statusCode: webhook_deliveries_1.webhookDeliveries.lastStatusCode,
|
|
239
|
+
createdAt: webhook_deliveries_1.webhookDeliveries.createdAt
|
|
240
|
+
})
|
|
241
|
+
.from(webhook_deliveries_1.webhookDeliveries)
|
|
242
|
+
.where((0, drizzle_orm_1.inArray)(webhook_deliveries_1.webhookDeliveries.endpointId, endpointIds))
|
|
243
|
+
.orderBy(webhook_deliveries_1.webhookDeliveries.endpointId, (0, drizzle_orm_1.desc)(webhook_deliveries_1.webhookDeliveries.createdAt));
|
|
244
|
+
return new Map(rows.map((row) => [
|
|
245
|
+
row.endpointId,
|
|
246
|
+
{
|
|
247
|
+
id: row.id,
|
|
248
|
+
status: row.status,
|
|
249
|
+
eventKind: row.eventKind,
|
|
250
|
+
statusCode: row.statusCode,
|
|
251
|
+
createdAt: row.createdAt.toISOString()
|
|
252
|
+
}
|
|
253
|
+
]));
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
exports.WebhookEndpointRepository = WebhookEndpointRepository;
|
|
257
|
+
exports.WebhookEndpointRepository = WebhookEndpointRepository = tslib_1.__decorate([
|
|
258
|
+
(0, common_1.Injectable)(),
|
|
259
|
+
tslib_1.__param(0, (0, database_1.InjectDatabase)()),
|
|
260
|
+
tslib_1.__metadata("design:paramtypes", [Object])
|
|
261
|
+
], WebhookEndpointRepository);
|
|
262
|
+
/** Row + its relations → the API shape. */
|
|
263
|
+
function toView(row, workspaceIds, lastDelivery) {
|
|
264
|
+
return {
|
|
265
|
+
id: row.id,
|
|
266
|
+
name: row.name,
|
|
267
|
+
url: row.url,
|
|
268
|
+
secretHint: row.secretHint,
|
|
269
|
+
enabled: row.enabled,
|
|
270
|
+
eventKinds: asStringArray(row.eventKinds),
|
|
271
|
+
contentTypes: asStringArray(row.contentTypes),
|
|
272
|
+
allWorkspaces: row.allWorkspaces,
|
|
273
|
+
workspaceIds: row.allWorkspaces ? [] : workspaceIds,
|
|
274
|
+
headers: asHeaderMap(row.headers),
|
|
275
|
+
disabledReason: row.disabledReason,
|
|
276
|
+
consecutiveFailures: row.consecutiveFailures,
|
|
277
|
+
createdAt: row.createdAt.toISOString(),
|
|
278
|
+
updatedAt: row.updatedAt.toISOString(),
|
|
279
|
+
lastDelivery
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* A `jsonb` column read back as a string array.
|
|
284
|
+
*
|
|
285
|
+
* Drizzle types `jsonb` as `unknown`, and a row written before a column's shape
|
|
286
|
+
* settled — or by hand — could hold anything. Coercing here keeps every reader
|
|
287
|
+
* from repeating the check, and an unexpected shape degrades to "empty", which
|
|
288
|
+
* for these two columns means "everything" rather than a crash.
|
|
289
|
+
*/
|
|
290
|
+
function asStringArray(value) {
|
|
291
|
+
return Array.isArray(value)
|
|
292
|
+
? value.filter((item) => typeof item === 'string')
|
|
293
|
+
: [];
|
|
294
|
+
}
|
|
295
|
+
/** A `jsonb` column read back as a header map, dropping non-string values. */
|
|
296
|
+
function asHeaderMap(value) {
|
|
297
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
298
|
+
return {};
|
|
299
|
+
}
|
|
300
|
+
const headers = {};
|
|
301
|
+
for (const [key, item] of Object.entries(value)) {
|
|
302
|
+
if (typeof item === 'string')
|
|
303
|
+
headers[key] = item;
|
|
304
|
+
}
|
|
305
|
+
return headers;
|
|
306
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { type OnApplicationBootstrap } from '@nestjs/common';
|
|
2
|
+
import { OutboxDispatcher, type DomainEvent, type DomainEventSubscriber } from '@orthacms/database';
|
|
3
|
+
import { WebhookDeliveryRepository } from './webhook-delivery.repository';
|
|
4
|
+
import { WebhookEndpointRepository } from './webhook-endpoint.repository';
|
|
5
|
+
/**
|
|
6
|
+
* Turns a domain event into queued deliveries — and does **nothing else**.
|
|
7
|
+
*
|
|
8
|
+
* This subscriber runs inside `OutboxDispatcher`'s claim transaction, which is
|
|
9
|
+
* the single most important fact about it. One `SELECT` over the endpoints and
|
|
10
|
+
* one batched `INSERT` is all it may cost: an outgoing HTTP request here would
|
|
11
|
+
* hold that transaction and its pool client open for a stranger's response
|
|
12
|
+
* time, and a thrown error would count against the **outbox row's** attempt
|
|
13
|
+
* budget, eventually dead-lettering an event the activity log and the alarms
|
|
14
|
+
* evaluator were also waiting for.
|
|
15
|
+
*
|
|
16
|
+
* The actual sending is {@link WebhookDeliveryWorker}'s job, with no
|
|
17
|
+
* transaction open.
|
|
18
|
+
*
|
|
19
|
+
* Delivery from the outbox is at-least-once, so this may see the same event
|
|
20
|
+
* twice; `unique(endpoint_id, event_id)` on the delivery row makes the repeat a
|
|
21
|
+
* no-op rather than a second POST.
|
|
22
|
+
*/
|
|
23
|
+
export declare class WebhookFanoutSubscriber implements DomainEventSubscriber, OnApplicationBootstrap {
|
|
24
|
+
private readonly dispatcher;
|
|
25
|
+
private readonly endpoints;
|
|
26
|
+
private readonly deliveries;
|
|
27
|
+
private readonly logger;
|
|
28
|
+
/** The dispatcher's delivery allow-list — the catalogue, minus `ping`. */
|
|
29
|
+
readonly kinds: readonly string[];
|
|
30
|
+
constructor(dispatcher: OutboxDispatcher, endpoints: WebhookEndpointRepository, deliveries: WebhookDeliveryRepository);
|
|
31
|
+
/** Registers with the dispatcher once the app is up. */
|
|
32
|
+
onApplicationBootstrap(): void;
|
|
33
|
+
/** Queues one delivery per endpoint that subscribes to this event. */
|
|
34
|
+
handle(event: DomainEvent): Promise<void>;
|
|
35
|
+
}
|
|
36
|
+
//# sourceMappingURL=webhook-fanout.subscriber.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"webhook-fanout.subscriber.d.ts","sourceRoot":"","sources":["../../../src/lib/infrastructure/webhook-fanout.subscriber.ts"],"names":[],"mappings":"AAAA,OAAO,EAGH,KAAK,sBAAsB,EAC9B,MAAM,gBAAgB,CAAC;AAExB,OAAO,EACH,gBAAgB,EAChB,KAAK,WAAW,EAChB,KAAK,qBAAqB,EAC7B,MAAM,oBAAoB,CAAC;AAM5B,OAAO,EAAE,yBAAyB,EAAE,MAAM,+BAA+B,CAAC;AAC1E,OAAO,EAAE,yBAAyB,EAAE,MAAM,+BAA+B,CAAC;AAG1E;;;;;;;;;;;;;;;;;GAiBG;AACH,qBACa,uBACT,YAAW,qBAAqB,EAAE,sBAAsB;IAQpD,OAAO,CAAC,QAAQ,CAAC,UAAU;IAC3B,OAAO,CAAC,QAAQ,CAAC,SAAS;IAC1B,OAAO,CAAC,QAAQ,CAAC,UAAU;IAR/B,OAAO,CAAC,QAAQ,CAAC,MAAM,CAA4C;IAEnE,0EAA0E;IAC1E,QAAQ,CAAC,KAAK,oBAA6B;gBAGtB,UAAU,EAAE,gBAAgB,EAC5B,SAAS,EAAE,yBAAyB,EACpC,UAAU,EAAE,yBAAyB;IAG1D,wDAAwD;IACxD,sBAAsB,IAAI,IAAI;IAI9B,sEAAsE;IAChE,MAAM,CAAC,KAAK,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC;CAmClD"}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var WebhookFanoutSubscriber_1;
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
exports.WebhookFanoutSubscriber = void 0;
|
|
5
|
+
const tslib_1 = require("tslib");
|
|
6
|
+
const common_1 = require("@nestjs/common");
|
|
7
|
+
const node_crypto_1 = require("node:crypto");
|
|
8
|
+
const database_1 = require("@orthacms/database");
|
|
9
|
+
const webhooks_domain_1 = require("@orthacms/webhooks-domain");
|
|
10
|
+
const webhook_delivery_repository_1 = require("./webhook-delivery.repository");
|
|
11
|
+
const webhook_endpoint_repository_1 = require("./webhook-endpoint.repository");
|
|
12
|
+
const event_mapping_1 = require("./event-mapping");
|
|
13
|
+
/**
|
|
14
|
+
* Turns a domain event into queued deliveries — and does **nothing else**.
|
|
15
|
+
*
|
|
16
|
+
* This subscriber runs inside `OutboxDispatcher`'s claim transaction, which is
|
|
17
|
+
* the single most important fact about it. One `SELECT` over the endpoints and
|
|
18
|
+
* one batched `INSERT` is all it may cost: an outgoing HTTP request here would
|
|
19
|
+
* hold that transaction and its pool client open for a stranger's response
|
|
20
|
+
* time, and a thrown error would count against the **outbox row's** attempt
|
|
21
|
+
* budget, eventually dead-lettering an event the activity log and the alarms
|
|
22
|
+
* evaluator were also waiting for.
|
|
23
|
+
*
|
|
24
|
+
* The actual sending is {@link WebhookDeliveryWorker}'s job, with no
|
|
25
|
+
* transaction open.
|
|
26
|
+
*
|
|
27
|
+
* Delivery from the outbox is at-least-once, so this may see the same event
|
|
28
|
+
* twice; `unique(endpoint_id, event_id)` on the delivery row makes the repeat a
|
|
29
|
+
* no-op rather than a second POST.
|
|
30
|
+
*/
|
|
31
|
+
let WebhookFanoutSubscriber = WebhookFanoutSubscriber_1 = class WebhookFanoutSubscriber {
|
|
32
|
+
dispatcher;
|
|
33
|
+
endpoints;
|
|
34
|
+
deliveries;
|
|
35
|
+
logger = new common_1.Logger(WebhookFanoutSubscriber_1.name);
|
|
36
|
+
/** The dispatcher's delivery allow-list — the catalogue, minus `ping`. */
|
|
37
|
+
kinds = webhooks_domain_1.SUBSCRIBABLE_OUTBOX_KINDS;
|
|
38
|
+
constructor(dispatcher, endpoints, deliveries) {
|
|
39
|
+
this.dispatcher = dispatcher;
|
|
40
|
+
this.endpoints = endpoints;
|
|
41
|
+
this.deliveries = deliveries;
|
|
42
|
+
}
|
|
43
|
+
/** Registers with the dispatcher once the app is up. */
|
|
44
|
+
onApplicationBootstrap() {
|
|
45
|
+
this.dispatcher.register(this);
|
|
46
|
+
}
|
|
47
|
+
/** Queues one delivery per endpoint that subscribes to this event. */
|
|
48
|
+
async handle(event) {
|
|
49
|
+
const subscriptions = await this.endpoints.listSubscriptions();
|
|
50
|
+
if (subscriptions.length === 0)
|
|
51
|
+
return;
|
|
52
|
+
const routable = (0, event_mapping_1.toRoutableEvent)(event);
|
|
53
|
+
const interested = subscriptions.filter((subscription) => (0, webhooks_domain_1.matches)(subscription, routable));
|
|
54
|
+
if (interested.length === 0)
|
|
55
|
+
return;
|
|
56
|
+
const source = (0, event_mapping_1.toSourceEvent)(event);
|
|
57
|
+
const queued = await this.deliveries.enqueue(interested.map((subscription) => {
|
|
58
|
+
// The id is minted here rather than by the database because the
|
|
59
|
+
// envelope carries it: the body and the `X-Ortha-Delivery`
|
|
60
|
+
// header have to name the same delivery.
|
|
61
|
+
const deliveryId = (0, node_crypto_1.randomUUID)();
|
|
62
|
+
return {
|
|
63
|
+
id: deliveryId,
|
|
64
|
+
endpointId: subscription.id,
|
|
65
|
+
eventId: event.eventId,
|
|
66
|
+
eventKind: event.kind,
|
|
67
|
+
workspaceId: routable.workspaceId,
|
|
68
|
+
contentType: routable.contentType,
|
|
69
|
+
payload: (0, webhooks_domain_1.buildEnvelope)(deliveryId, source)
|
|
70
|
+
};
|
|
71
|
+
}));
|
|
72
|
+
if (queued > 0) {
|
|
73
|
+
this.logger.debug(`Queued ${queued} webhook deliver${queued === 1 ? 'y' : 'ies'} for ${event.kind}.`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
exports.WebhookFanoutSubscriber = WebhookFanoutSubscriber;
|
|
78
|
+
exports.WebhookFanoutSubscriber = WebhookFanoutSubscriber = WebhookFanoutSubscriber_1 = tslib_1.__decorate([
|
|
79
|
+
(0, common_1.Injectable)(),
|
|
80
|
+
tslib_1.__metadata("design:paramtypes", [database_1.OutboxDispatcher,
|
|
81
|
+
webhook_endpoint_repository_1.WebhookEndpointRepository,
|
|
82
|
+
webhook_delivery_repository_1.WebhookDeliveryRepository])
|
|
83
|
+
], WebhookFanoutSubscriber);
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { ResolvedWebhooksConfig } from '../types/webhooks-config';
|
|
2
|
+
/** Everything one POST needs. */
|
|
3
|
+
export interface WebhookRequest {
|
|
4
|
+
url: string;
|
|
5
|
+
secret: string;
|
|
6
|
+
/** Extra static headers from the endpoint's configuration. */
|
|
7
|
+
headers: Record<string, string>;
|
|
8
|
+
/** The envelope. Serialised once, because the signature covers the bytes. */
|
|
9
|
+
body: unknown;
|
|
10
|
+
eventKind: string;
|
|
11
|
+
deliveryId: string;
|
|
12
|
+
eventId: string;
|
|
13
|
+
workspaceId: string | null;
|
|
14
|
+
/** 1-based, sent as `X-Ortha-Attempt`. */
|
|
15
|
+
attempt: number;
|
|
16
|
+
}
|
|
17
|
+
/** What came back. */
|
|
18
|
+
export interface WebhookResponse {
|
|
19
|
+
/** The HTTP status, or null when nothing answered. */
|
|
20
|
+
statusCode: number | null;
|
|
21
|
+
/** A `Retry-After`, in milliseconds from now, when the receiver sent one. */
|
|
22
|
+
retryAfterMs?: number;
|
|
23
|
+
/** Why it failed, when no status came back. */
|
|
24
|
+
error: string | null;
|
|
25
|
+
/** The first `responseSnippetBytes` of the body. */
|
|
26
|
+
responseSnippet: string | null;
|
|
27
|
+
durationMs: number;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Posts one delivery, under the policy that decides where this server may be
|
|
31
|
+
* talked into connecting.
|
|
32
|
+
*
|
|
33
|
+
* **Why an `undici.Agent` and not `fetch`.** The address has to be checked
|
|
34
|
+
* *after* the hostname resolves and the connection has to go to that same
|
|
35
|
+
* address. Global `fetch` gives no hook between the two, so a name that passes
|
|
36
|
+
* a pre-flight check and then resolves to `169.254.169.254` — DNS rebinding —
|
|
37
|
+
* connects anyway. A custom `lookup` is the seam where the resolved address is
|
|
38
|
+
* judged and either handed to the connector or refused.
|
|
39
|
+
*
|
|
40
|
+
* Redirects are not followed. A `3xx` is reported as a failed delivery rather
|
|
41
|
+
* than an invitation to make a second request, because following one is the
|
|
42
|
+
* simplest way to be walked to an internal address after the first hop passed.
|
|
43
|
+
*/
|
|
44
|
+
export declare class WebhookHttpClient {
|
|
45
|
+
private readonly config;
|
|
46
|
+
private readonly logger;
|
|
47
|
+
/**
|
|
48
|
+
* One agent for every delivery, so connections are pooled across them.
|
|
49
|
+
* Built lazily because it captures the policy from injected config.
|
|
50
|
+
*/
|
|
51
|
+
private agent;
|
|
52
|
+
constructor(config: ResolvedWebhooksConfig);
|
|
53
|
+
/** Sends one delivery and reports what happened. Never throws. */
|
|
54
|
+
send(delivery: WebhookRequest): Promise<WebhookResponse>;
|
|
55
|
+
/** The URL policy this deployment configured. */
|
|
56
|
+
private policy;
|
|
57
|
+
/**
|
|
58
|
+
* The agent, with the address check wired into its DNS lookup.
|
|
59
|
+
*
|
|
60
|
+
* `lookup` is called by the connector with the hostname it is about to
|
|
61
|
+
* connect to; refusing here means the socket is never opened, and the
|
|
62
|
+
* address that was judged is the address that would have been used.
|
|
63
|
+
*/
|
|
64
|
+
private dispatcher;
|
|
65
|
+
/** The headers one delivery travels with. */
|
|
66
|
+
private headersFor;
|
|
67
|
+
/**
|
|
68
|
+
* Reads at most `responseSnippetBytes` of the body, then abandons the rest.
|
|
69
|
+
*
|
|
70
|
+
* A receiver that answers with a gigabyte would otherwise be able to
|
|
71
|
+
* exhaust this process's memory from the far end of a connection we
|
|
72
|
+
* initiated.
|
|
73
|
+
*/
|
|
74
|
+
private readSnippet;
|
|
75
|
+
}
|
|
76
|
+
//# sourceMappingURL=webhook-http.client.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"webhook-http.client.d.ts","sourceRoot":"","sources":["../../../src/lib/infrastructure/webhook-http.client.ts"],"names":[],"mappings":"AAcA,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,0BAA0B,CAAC;AAEvE,iCAAiC;AACjC,MAAM,WAAW,cAAc;IAC3B,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE,MAAM,CAAC;IACf,8DAA8D;IAC9D,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAChC,6EAA6E;IAC7E,IAAI,EAAE,OAAO,CAAC;IACd,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,0CAA0C;IAC1C,OAAO,EAAE,MAAM,CAAC;CACnB;AAED,sBAAsB;AACtB,MAAM,WAAW,eAAe;IAC5B,sDAAsD;IACtD,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,6EAA6E;IAC7E,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,+CAA+C;IAC/C,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,oDAAoD;IACpD,eAAe,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,UAAU,EAAE,MAAM,CAAC;CACtB;AAED;;;;;;;;;;;;;;GAcG;AACH,qBACa,iBAAiB;IAWtB,OAAO,CAAC,QAAQ,CAAC,MAAM;IAV3B,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAsC;IAE7D;;;OAGG;IACH,OAAO,CAAC,KAAK,CAAsB;gBAId,MAAM,EAAE,sBAAsB;IAGnD,kEAAkE;IAC5D,IAAI,CAAC,QAAQ,EAAE,cAAc,GAAG,OAAO,CAAC,eAAe,CAAC;IA4C9D,iDAAiD;IACjD,OAAO,CAAC,MAAM;IAOd;;;;;;OAMG;IACH,OAAO,CAAC,UAAU;IAiClB,6CAA6C;IAC7C,OAAO,CAAC,UAAU;IAiClB;;;;;;OAMG;YACW,WAAW;CA8B5B"}
|