@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.
Files changed (90) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +7 -0
  3. package/dist/index.d.ts +19 -0
  4. package/dist/index.d.ts.map +1 -0
  5. package/dist/index.js +33 -0
  6. package/dist/lib/application/dto/list-deliveries-query.dto.d.ts +8 -0
  7. package/dist/lib/application/dto/list-deliveries-query.dto.d.ts.map +1 -0
  8. package/dist/lib/application/dto/list-deliveries-query.dto.js +64 -0
  9. package/dist/lib/application/dto/save-webhook-endpoint.dto.d.ts +23 -0
  10. package/dist/lib/application/dto/save-webhook-endpoint.dto.d.ts.map +1 -0
  11. package/dist/lib/application/dto/save-webhook-endpoint.dto.js +127 -0
  12. package/dist/lib/application/webhook-endpoints.service.d.ts +82 -0
  13. package/dist/lib/application/webhook-endpoints.service.d.ts.map +1 -0
  14. package/dist/lib/application/webhook-endpoints.service.js +213 -0
  15. package/dist/lib/docs/describe-webhooks-api.d.ts +16 -0
  16. package/dist/lib/docs/describe-webhooks-api.d.ts.map +1 -0
  17. package/dist/lib/docs/describe-webhooks-api.js +170 -0
  18. package/dist/lib/docs/webhook-schemas.d.ts +21 -0
  19. package/dist/lib/docs/webhook-schemas.d.ts.map +1 -0
  20. package/dist/lib/docs/webhook-schemas.js +360 -0
  21. package/dist/lib/domain/errors/index.d.ts +3 -0
  22. package/dist/lib/domain/errors/index.d.ts.map +1 -0
  23. package/dist/lib/domain/errors/index.js +7 -0
  24. package/dist/lib/domain/errors/webhook-delivery-not-found.error.d.ts +12 -0
  25. package/dist/lib/domain/errors/webhook-delivery-not-found.error.d.ts.map +1 -0
  26. package/dist/lib/domain/errors/webhook-delivery-not-found.error.js +19 -0
  27. package/dist/lib/domain/errors/webhook-endpoint-not-found.error.d.ts +6 -0
  28. package/dist/lib/domain/errors/webhook-endpoint-not-found.error.d.ts.map +1 -0
  29. package/dist/lib/domain/errors/webhook-endpoint-not-found.error.js +13 -0
  30. package/dist/lib/domain/webhook-secret.d.ts +29 -0
  31. package/dist/lib/domain/webhook-secret.d.ts.map +1 -0
  32. package/dist/lib/domain/webhook-secret.js +34 -0
  33. package/dist/lib/domain/webhook-views.d.ts +85 -0
  34. package/dist/lib/domain/webhook-views.d.ts.map +1 -0
  35. package/dist/lib/domain/webhook-views.js +2 -0
  36. package/dist/lib/http/controllers/webhook-deliveries.controller.d.ts +28 -0
  37. package/dist/lib/http/controllers/webhook-deliveries.controller.d.ts.map +1 -0
  38. package/dist/lib/http/controllers/webhook-deliveries.controller.js +110 -0
  39. package/dist/lib/http/controllers/webhook-endpoints.controller.d.ts +61 -0
  40. package/dist/lib/http/controllers/webhook-endpoints.controller.d.ts.map +1 -0
  41. package/dist/lib/http/controllers/webhook-endpoints.controller.js +213 -0
  42. package/dist/lib/http/controllers/webhook-events.controller.d.ts +13 -0
  43. package/dist/lib/http/controllers/webhook-events.controller.d.ts.map +1 -0
  44. package/dist/lib/http/controllers/webhook-events.controller.js +38 -0
  45. package/dist/lib/infrastructure/event-mapping.d.ts +13 -0
  46. package/dist/lib/infrastructure/event-mapping.d.ts.map +1 -0
  47. package/dist/lib/infrastructure/event-mapping.js +69 -0
  48. package/dist/lib/infrastructure/purge/webhook-workspaces.purger.d.ts +42 -0
  49. package/dist/lib/infrastructure/purge/webhook-workspaces.purger.d.ts.map +1 -0
  50. package/dist/lib/infrastructure/purge/webhook-workspaces.purger.js +66 -0
  51. package/dist/lib/infrastructure/schema/index.d.ts +3 -0
  52. package/dist/lib/infrastructure/schema/index.d.ts.map +1 -0
  53. package/dist/lib/infrastructure/schema/index.js +8 -0
  54. package/dist/lib/infrastructure/schema/webhook-deliveries.d.ts +332 -0
  55. package/dist/lib/infrastructure/schema/webhook-deliveries.d.ts.map +1 -0
  56. package/dist/lib/infrastructure/schema/webhook-deliveries.js +92 -0
  57. package/dist/lib/infrastructure/schema/webhook-endpoints.d.ts +330 -0
  58. package/dist/lib/infrastructure/schema/webhook-endpoints.d.ts.map +1 -0
  59. package/dist/lib/infrastructure/schema/webhook-endpoints.js +94 -0
  60. package/dist/lib/infrastructure/webhook-delivery.repository.d.ts +99 -0
  61. package/dist/lib/infrastructure/webhook-delivery.repository.d.ts.map +1 -0
  62. package/dist/lib/infrastructure/webhook-delivery.repository.js +255 -0
  63. package/dist/lib/infrastructure/webhook-delivery.worker.d.ts +72 -0
  64. package/dist/lib/infrastructure/webhook-delivery.worker.d.ts.map +1 -0
  65. package/dist/lib/infrastructure/webhook-delivery.worker.js +216 -0
  66. package/dist/lib/infrastructure/webhook-endpoint.repository.d.ts +88 -0
  67. package/dist/lib/infrastructure/webhook-endpoint.repository.d.ts.map +1 -0
  68. package/dist/lib/infrastructure/webhook-endpoint.repository.js +306 -0
  69. package/dist/lib/infrastructure/webhook-fanout.subscriber.d.ts +36 -0
  70. package/dist/lib/infrastructure/webhook-fanout.subscriber.d.ts.map +1 -0
  71. package/dist/lib/infrastructure/webhook-fanout.subscriber.js +83 -0
  72. package/dist/lib/infrastructure/webhook-http.client.d.ts +76 -0
  73. package/dist/lib/infrastructure/webhook-http.client.d.ts.map +1 -0
  74. package/dist/lib/infrastructure/webhook-http.client.js +225 -0
  75. package/dist/lib/types/webhooks-config.d.ts +64 -0
  76. package/dist/lib/types/webhooks-config.d.ts.map +1 -0
  77. package/dist/lib/types/webhooks-config.js +24 -0
  78. package/dist/lib/utils/webhooks-plugin.d.ts +35 -0
  79. package/dist/lib/utils/webhooks-plugin.d.ts.map +1 -0
  80. package/dist/lib/utils/webhooks-plugin.js +90 -0
  81. package/dist/lib/webhooks.module.d.ts +18 -0
  82. package/dist/lib/webhooks.module.d.ts.map +1 -0
  83. package/dist/lib/webhooks.module.js +72 -0
  84. package/dist/lib/webhooks.tokens.d.ts +5 -0
  85. package/dist/lib/webhooks.tokens.d.ts.map +1 -0
  86. package/dist/lib/webhooks.tokens.js +9 -0
  87. package/migrations/0000_init_webhooks.sql +53 -0
  88. package/migrations/meta/0000_snapshot.json +424 -0
  89. package/migrations/meta/_journal.json +13 -0
  90. 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"}