@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,225 @@
1
+ "use strict";
2
+ var WebhookHttpClient_1;
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.WebhookHttpClient = void 0;
5
+ const tslib_1 = require("tslib");
6
+ const common_1 = require("@nestjs/common");
7
+ const node_dns_1 = require("node:dns");
8
+ const webhooks_domain_1 = require("@orthacms/webhooks-domain");
9
+ const undici_1 = require("undici");
10
+ const webhooks_tokens_1 = require("../webhooks.tokens");
11
+ /**
12
+ * Posts one delivery, under the policy that decides where this server may be
13
+ * talked into connecting.
14
+ *
15
+ * **Why an `undici.Agent` and not `fetch`.** The address has to be checked
16
+ * *after* the hostname resolves and the connection has to go to that same
17
+ * address. Global `fetch` gives no hook between the two, so a name that passes
18
+ * a pre-flight check and then resolves to `169.254.169.254` — DNS rebinding —
19
+ * connects anyway. A custom `lookup` is the seam where the resolved address is
20
+ * judged and either handed to the connector or refused.
21
+ *
22
+ * Redirects are not followed. A `3xx` is reported as a failed delivery rather
23
+ * than an invitation to make a second request, because following one is the
24
+ * simplest way to be walked to an internal address after the first hop passed.
25
+ */
26
+ let WebhookHttpClient = WebhookHttpClient_1 = class WebhookHttpClient {
27
+ config;
28
+ logger = new common_1.Logger(WebhookHttpClient_1.name);
29
+ /**
30
+ * One agent for every delivery, so connections are pooled across them.
31
+ * Built lazily because it captures the policy from injected config.
32
+ */
33
+ agent = null;
34
+ constructor(config) {
35
+ this.config = config;
36
+ }
37
+ /** Sends one delivery and reports what happened. Never throws. */
38
+ async send(delivery) {
39
+ const startedAt = Date.now();
40
+ const body = JSON.stringify(delivery.body);
41
+ const timestamp = Math.floor(startedAt / 1000);
42
+ try {
43
+ // Re-checked on every send, not only when the endpoint was saved:
44
+ // a hostname re-pointed at a private address afterwards must be
45
+ // refused here, and the URL policy may have been tightened since.
46
+ const url = (0, webhooks_domain_1.assertUrlShape)(delivery.url, this.policy());
47
+ const response = await (0, undici_1.request)(url, {
48
+ method: 'POST',
49
+ body,
50
+ headers: this.headersFor(delivery, body, timestamp),
51
+ dispatcher: this.dispatcher(),
52
+ // `request` does not follow redirects unless a redirect
53
+ // interceptor is added, and none is: a 3xx is reported as a
54
+ // failed delivery rather than followed, because following one
55
+ // is the simplest way to be walked to an internal address
56
+ // after the first hop already passed the address check.
57
+ headersTimeout: this.config.timeoutMs,
58
+ bodyTimeout: this.config.timeoutMs
59
+ });
60
+ const snippet = await this.readSnippet(response.body);
61
+ return {
62
+ statusCode: response.statusCode,
63
+ retryAfterMs: retryAfterOf(response.headers),
64
+ error: null,
65
+ responseSnippet: snippet,
66
+ durationMs: Date.now() - startedAt
67
+ };
68
+ }
69
+ catch (error) {
70
+ return {
71
+ statusCode: null,
72
+ error: describeFailure(error),
73
+ responseSnippet: null,
74
+ durationMs: Date.now() - startedAt
75
+ };
76
+ }
77
+ }
78
+ /** The URL policy this deployment configured. */
79
+ policy() {
80
+ return {
81
+ allowInsecureUrls: this.config.allowInsecureUrls,
82
+ allowPrivateNetworks: this.config.allowPrivateNetworks
83
+ };
84
+ }
85
+ /**
86
+ * The agent, with the address check wired into its DNS lookup.
87
+ *
88
+ * `lookup` is called by the connector with the hostname it is about to
89
+ * connect to; refusing here means the socket is never opened, and the
90
+ * address that was judged is the address that would have been used.
91
+ */
92
+ dispatcher() {
93
+ if (this.agent)
94
+ return this.agent;
95
+ const policy = this.policy();
96
+ this.agent = new undici_1.Agent({
97
+ connect: {
98
+ timeout: this.config.timeoutMs,
99
+ lookup: (hostname, options, callback) => {
100
+ (0, node_dns_1.lookup)(hostname, options, (error, address, family) => {
101
+ if (error) {
102
+ callback(error, address, family);
103
+ return;
104
+ }
105
+ try {
106
+ for (const resolved of toAddressList(address)) {
107
+ (0, webhooks_domain_1.assertAddressAllowed)(resolved, policy);
108
+ }
109
+ }
110
+ catch (rejection) {
111
+ callback(rejection, address, family);
112
+ return;
113
+ }
114
+ callback(null, address, family);
115
+ });
116
+ }
117
+ }
118
+ });
119
+ return this.agent;
120
+ }
121
+ /** The headers one delivery travels with. */
122
+ headersFor(delivery, body, timestamp) {
123
+ const headers = {};
124
+ // The endpoint's own headers go first, so the reserved ones below can
125
+ // never be overwritten by configuration. `isAllowedCustomHeader` is
126
+ // also enforced on write; this is the belt to that braces, because a
127
+ // row could predate the check.
128
+ for (const [name, value] of Object.entries(delivery.headers)) {
129
+ if ((0, webhooks_domain_1.isAllowedCustomHeader)(name))
130
+ headers[name] = value;
131
+ }
132
+ headers['content-type'] = 'application/json';
133
+ headers['user-agent'] = webhooks_domain_1.DELIVERY_USER_AGENT;
134
+ headers[webhooks_domain_1.DELIVERY_HEADERS.EVENT] = delivery.eventKind;
135
+ headers[webhooks_domain_1.DELIVERY_HEADERS.DELIVERY] = delivery.deliveryId;
136
+ headers[webhooks_domain_1.DELIVERY_HEADERS.EVENT_ID] = delivery.eventId;
137
+ headers[webhooks_domain_1.DELIVERY_HEADERS.ATTEMPT] = String(delivery.attempt);
138
+ headers[webhooks_domain_1.DELIVERY_HEADERS.SIGNATURE] = (0, webhooks_domain_1.signatureHeader)(delivery.secret, timestamp, body);
139
+ if (delivery.workspaceId) {
140
+ headers[webhooks_domain_1.DELIVERY_HEADERS.WORKSPACE] = delivery.workspaceId;
141
+ }
142
+ return headers;
143
+ }
144
+ /**
145
+ * Reads at most `responseSnippetBytes` of the body, then abandons the rest.
146
+ *
147
+ * A receiver that answers with a gigabyte would otherwise be able to
148
+ * exhaust this process's memory from the far end of a connection we
149
+ * initiated.
150
+ */
151
+ async readSnippet(body) {
152
+ const chunks = [];
153
+ let size = 0;
154
+ try {
155
+ for await (const chunk of body) {
156
+ const buffer = Buffer.isBuffer(chunk)
157
+ ? chunk
158
+ : Buffer.from(chunk);
159
+ chunks.push(buffer);
160
+ size += buffer.length;
161
+ if (size >= this.config.responseSnippetBytes)
162
+ break;
163
+ }
164
+ }
165
+ catch (error) {
166
+ this.logger.debug(`Could not read the response body: ${describeFailure(error)}`);
167
+ }
168
+ finally {
169
+ // Whether we stopped early or the stream errored, the connection
170
+ // must not be left half-read or the pooled socket is unusable.
171
+ body.resume?.();
172
+ }
173
+ if (chunks.length === 0)
174
+ return null;
175
+ return Buffer.concat(chunks)
176
+ .subarray(0, this.config.responseSnippetBytes)
177
+ .toString('utf8');
178
+ }
179
+ };
180
+ exports.WebhookHttpClient = WebhookHttpClient;
181
+ exports.WebhookHttpClient = WebhookHttpClient = WebhookHttpClient_1 = tslib_1.__decorate([
182
+ (0, common_1.Injectable)(),
183
+ tslib_1.__param(0, (0, webhooks_tokens_1.InjectWebhooksConfig)()),
184
+ tslib_1.__metadata("design:paramtypes", [Object])
185
+ ], WebhookHttpClient);
186
+ /**
187
+ * A `Retry-After` from the response headers, in milliseconds.
188
+ *
189
+ * Parsed in the domain so the header's two legal forms live in one place;
190
+ * `undefined` simply falls back to our own schedule.
191
+ */
192
+ function retryAfterOf(headers) {
193
+ const raw = headers['retry-after'];
194
+ const value = Array.isArray(raw) ? raw[0] : raw;
195
+ return value ? (0, webhooks_domain_1.parseRetryAfter)(value) : undefined;
196
+ }
197
+ /** `lookup` may hand back one address or a list; normalise to a list. */
198
+ function toAddressList(address) {
199
+ return typeof address === 'string'
200
+ ? [address]
201
+ : address.map((entry) => entry.address);
202
+ }
203
+ /** A failure, phrased for the delivery log rather than for a stack trace. */
204
+ function describeFailure(error) {
205
+ if (error instanceof webhooks_domain_1.WebhookUrlRejectedError)
206
+ return error.message;
207
+ if (error instanceof Error) {
208
+ const code = error.code;
209
+ if (code === 'UND_ERR_HEADERS_TIMEOUT' ||
210
+ code === 'UND_ERR_BODY_TIMEOUT') {
211
+ return 'The receiver did not respond in time.';
212
+ }
213
+ if (code === 'UND_ERR_CONNECT_TIMEOUT') {
214
+ return 'Could not connect to the receiver in time.';
215
+ }
216
+ if (code === 'ENOTFOUND') {
217
+ return 'The host name could not be resolved.';
218
+ }
219
+ if (code === 'ECONNREFUSED') {
220
+ return 'The receiver refused the connection.';
221
+ }
222
+ return error.message;
223
+ }
224
+ return String(error);
225
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Configuration for the webhooks plugin.
3
+ *
4
+ * Everything here is a deployment concern — how hard the worker is allowed to
5
+ * push, how long it waits, what it may reach. The endpoints themselves live in
6
+ * the database, because which systems a CMS notifies is operational data that
7
+ * changes without a redeploy.
8
+ */
9
+ export interface WebhooksPluginConfig {
10
+ /**
11
+ * How often, in milliseconds, the worker looks for claimable deliveries.
12
+ * `0` turns delivery off entirely — rows still queue, so a deployment can
13
+ * run the API without a sender and let one process do the sending.
14
+ */
15
+ deliveryIntervalMs?: number;
16
+ /**
17
+ * How many deliveries one tick claims. Bounds the burst a single process
18
+ * aims at receivers, and the memory one tick holds.
19
+ */
20
+ batchSize?: number;
21
+ /** Per-request timeout in milliseconds. */
22
+ timeoutMs?: number;
23
+ /** How many attempts a delivery gets before it is given up on. */
24
+ maxAttempts?: number;
25
+ /**
26
+ * How long a completed delivery stays in the log, in days. `0` keeps them
27
+ * forever — which is a real choice for a low-volume install, and a table
28
+ * that grows without bound for any other.
29
+ */
30
+ retentionDays?: number;
31
+ /**
32
+ * How many consecutive dead deliveries switch an endpoint off. Without a
33
+ * ceiling, a staging URL that was torn down months ago keeps generating six
34
+ * requests per save for the life of the deployment.
35
+ */
36
+ autoDisableAfter?: number;
37
+ /**
38
+ * Permit `http://` destinations. Off by default — a signature over
39
+ * plaintext still leaks the payload to the network path.
40
+ */
41
+ allowInsecureUrls?: boolean;
42
+ /**
43
+ * Permit loopback, link-local and RFC 1918 destinations. Off by default;
44
+ * a self-hosted install whose receiver shares the cluster turns it on
45
+ * deliberately.
46
+ */
47
+ allowPrivateNetworks?: boolean;
48
+ /**
49
+ * How long a row may sit in `delivering` before the reaper assumes the
50
+ * worker that claimed it died, in milliseconds. Must comfortably exceed
51
+ * {@link timeoutMs}, or a slow-but-live request gets reclaimed and sent
52
+ * twice.
53
+ */
54
+ claimTimeoutMs?: number;
55
+ /** Bytes of the response body kept for the delivery log. */
56
+ responseSnippetBytes?: number;
57
+ }
58
+ /** Config with every optional filled in — what the services actually read. */
59
+ export type ResolvedWebhooksConfig = Required<WebhooksPluginConfig>;
60
+ /** Defaults chosen to be safe next to someone else's server, not fast. */
61
+ export declare const WEBHOOKS_DEFAULTS: ResolvedWebhooksConfig;
62
+ /** Fills the defaults in, so no service has to repeat a `??`. */
63
+ export declare function resolveWebhooksConfig(config?: WebhooksPluginConfig): ResolvedWebhooksConfig;
64
+ //# sourceMappingURL=webhooks-config.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"webhooks-config.d.ts","sourceRoot":"","sources":["../../../src/lib/types/webhooks-config.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,MAAM,WAAW,oBAAoB;IACjC;;;;OAIG;IACH,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAE5B;;;OAGG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IAEnB,2CAA2C;IAC3C,SAAS,CAAC,EAAE,MAAM,CAAC;IAEnB,kEAAkE;IAClE,WAAW,CAAC,EAAE,MAAM,CAAC;IAErB;;;;OAIG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;IAEvB;;;;OAIG;IACH,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAE1B;;;OAGG;IACH,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAE5B;;;;OAIG;IACH,oBAAoB,CAAC,EAAE,OAAO,CAAC;IAE/B;;;;;OAKG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IAExB,4DAA4D;IAC5D,oBAAoB,CAAC,EAAE,MAAM,CAAC;CACjC;AAED,8EAA8E;AAC9E,MAAM,MAAM,sBAAsB,GAAG,QAAQ,CAAC,oBAAoB,CAAC,CAAC;AAEpE,0EAA0E;AAC1E,eAAO,MAAM,iBAAiB,EAAE,sBAc/B,CAAC;AAEF,iEAAiE;AACjE,wBAAgB,qBAAqB,CACjC,MAAM,GAAE,oBAAyB,GAClC,sBAAsB,CAExB"}
@@ -0,0 +1,24 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.WEBHOOKS_DEFAULTS = void 0;
4
+ exports.resolveWebhooksConfig = resolveWebhooksConfig;
5
+ /** Defaults chosen to be safe next to someone else's server, not fast. */
6
+ exports.WEBHOOKS_DEFAULTS = {
7
+ deliveryIntervalMs: 2_000,
8
+ batchSize: 20,
9
+ timeoutMs: 10_000,
10
+ maxAttempts: 6,
11
+ retentionDays: 30,
12
+ autoDisableAfter: 20,
13
+ allowInsecureUrls: false,
14
+ allowPrivateNetworks: false,
15
+ // Three times the request timeout: long enough that a slow receiver is
16
+ // never reclaimed underneath a live request, short enough that a crashed
17
+ // worker's rows move again within a minute.
18
+ claimTimeoutMs: 30_000,
19
+ responseSnippetBytes: 2_048
20
+ };
21
+ /** Fills the defaults in, so no service has to repeat a `??`. */
22
+ function resolveWebhooksConfig(config = {}) {
23
+ return { ...exports.WEBHOOKS_DEFAULTS, ...config };
24
+ }
@@ -0,0 +1,35 @@
1
+ import type { ServerPlugin } from '@orthacms/bootstrap-server';
2
+ import type { WebhooksPluginConfig } from '../types/webhooks-config';
3
+ /** The webhooks plugin, carrying its config alongside the standard shape. */
4
+ export interface WebhooksServerPluginType extends ServerPlugin {
5
+ /** The configuration this plugin was constructed with. */
6
+ webhooksConfig: WebhooksPluginConfig;
7
+ }
8
+ /**
9
+ * Creates the webhooks plugin — outgoing HTTP notifications about content
10
+ * changes.
11
+ *
12
+ * Register it **after** `DatabasePlugin` (the outbox it subscribes to and the
13
+ * client it injects) and after `IdentityPlugin` (the permissions its routes are
14
+ * gated on). It owns three tables — `webhook_endpoints`,
15
+ * `webhook_endpoint_workspaces` and `webhook_deliveries` — and ships their
16
+ * migrations under its own tracking table.
17
+ *
18
+ * It never delivers from inside the outbox subscriber: the subscriber queues
19
+ * rows, and a worker sends them with no transaction open. See ADR-0016 for why
20
+ * that separation is a rule rather than an implementation detail.
21
+ *
22
+ * @example
23
+ * ```typescript
24
+ * createServer({
25
+ * plugins: [
26
+ * DatabasePlugin({ connectionString: config.database.url }),
27
+ * IdentityPlugin(config.plugins.identity),
28
+ * ContentPlugin({ types: contentTypes }),
29
+ * WebhooksPlugin({ retentionDays: 30 })
30
+ * ]
31
+ * });
32
+ * ```
33
+ */
34
+ export declare function WebhooksPlugin(config?: WebhooksPluginConfig): WebhooksServerPluginType;
35
+ //# sourceMappingURL=webhooks-plugin.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"webhooks-plugin.d.ts","sourceRoot":"","sources":["../../../src/lib/utils/webhooks-plugin.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,4BAA4B,CAAC;AAG/D,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,0BAA0B,CAAC;AAErE,6EAA6E;AAC7E,MAAM,WAAW,wBAAyB,SAAQ,YAAY;IAC1D,0DAA0D;IAC1D,cAAc,EAAE,oBAAoB,CAAC;CACxC;AAqDD;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,wBAAgB,cAAc,CAC1B,MAAM,GAAE,oBAAyB,GAClC,wBAAwB,CAgB1B"}
@@ -0,0 +1,90 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.WebhooksPlugin = WebhooksPlugin;
4
+ const node_path_1 = require("node:path");
5
+ const describe_webhooks_api_1 = require("../docs/describe-webhooks-api");
6
+ const webhooks_module_1 = require("../webhooks.module");
7
+ /**
8
+ * Rejects a configuration that would misbehave rather than fail loudly.
9
+ *
10
+ * The bounds here are not style: a zero `batchSize` is a worker that claims
11
+ * nothing and looks exactly like a worker with nothing to do, and a
12
+ * `claimTimeoutMs` under the request timeout reclaims deliveries out from under
13
+ * live requests and sends every slow one twice. Both are far cheaper to catch
14
+ * at construction than to diagnose from a delivery log.
15
+ */
16
+ function assertConfig(config) {
17
+ const positive = [
18
+ ['batchSize', config.batchSize],
19
+ ['timeoutMs', config.timeoutMs],
20
+ ['maxAttempts', config.maxAttempts],
21
+ ['autoDisableAfter', config.autoDisableAfter],
22
+ ['claimTimeoutMs', config.claimTimeoutMs],
23
+ ['responseSnippetBytes', config.responseSnippetBytes]
24
+ ];
25
+ for (const [key, value] of positive) {
26
+ if (value !== undefined && (!Number.isInteger(value) || value < 1)) {
27
+ throw new Error(`WebhooksPlugin: ${String(key)} must be a positive integer; got ${value}.`);
28
+ }
29
+ }
30
+ // Zero is a real choice for both of these — "do not send from this process"
31
+ // and "keep the log forever" — so only a negative value is refused.
32
+ const nonNegative = [
33
+ ['deliveryIntervalMs', config.deliveryIntervalMs],
34
+ ['retentionDays', config.retentionDays]
35
+ ];
36
+ for (const [key, value] of nonNegative) {
37
+ if (value !== undefined && (!Number.isInteger(value) || value < 0)) {
38
+ throw new Error(`WebhooksPlugin: ${String(key)} must be a non-negative integer (0 disables it); got ${value}.`);
39
+ }
40
+ }
41
+ const timeout = config.timeoutMs ?? 10_000;
42
+ const claim = config.claimTimeoutMs ?? 30_000;
43
+ if (claim <= timeout) {
44
+ throw new Error(`WebhooksPlugin: claimTimeoutMs (${claim}) must exceed timeoutMs (${timeout}), ` +
45
+ 'or a delivery still in flight is reclaimed by another worker and sent twice.');
46
+ }
47
+ }
48
+ /**
49
+ * Creates the webhooks plugin — outgoing HTTP notifications about content
50
+ * changes.
51
+ *
52
+ * Register it **after** `DatabasePlugin` (the outbox it subscribes to and the
53
+ * client it injects) and after `IdentityPlugin` (the permissions its routes are
54
+ * gated on). It owns three tables — `webhook_endpoints`,
55
+ * `webhook_endpoint_workspaces` and `webhook_deliveries` — and ships their
56
+ * migrations under its own tracking table.
57
+ *
58
+ * It never delivers from inside the outbox subscriber: the subscriber queues
59
+ * rows, and a worker sends them with no transaction open. See ADR-0016 for why
60
+ * that separation is a rule rather than an implementation detail.
61
+ *
62
+ * @example
63
+ * ```typescript
64
+ * createServer({
65
+ * plugins: [
66
+ * DatabasePlugin({ connectionString: config.database.url }),
67
+ * IdentityPlugin(config.plugins.identity),
68
+ * ContentPlugin({ types: contentTypes }),
69
+ * WebhooksPlugin({ retentionDays: 30 })
70
+ * ]
71
+ * });
72
+ * ```
73
+ */
74
+ function WebhooksPlugin(config = {}) {
75
+ assertConfig(config);
76
+ return {
77
+ name: 'webhooks',
78
+ module: webhooks_module_1.WebhooksModule.forRoot(config),
79
+ webhooksConfig: config,
80
+ migrations: {
81
+ dir: () => (0, node_path_1.join)(__dirname, '../../../migrations'),
82
+ table: '__drizzle_migrations_webhooks'
83
+ },
84
+ // Every response view here is an `interface`, which `@nestjs/swagger`
85
+ // cannot see — so the scanner emits an empty 200 for each route. This
86
+ // hook is where the reference learns what a delivery, an endpoint and
87
+ // a test result actually look like.
88
+ docs: { decorate: describe_webhooks_api_1.describeWebhooksApi }
89
+ };
90
+ }
@@ -0,0 +1,18 @@
1
+ import { type DynamicModule } from '@nestjs/common';
2
+ import { type WebhooksPluginConfig } from './types/webhooks-config';
3
+ /**
4
+ * The webhooks plugin's one dynamic module.
5
+ *
6
+ * `global: true` like every other plugin here, so a future surface — an MCP
7
+ * tool that lists endpoints, a copilot read tool — injects these services
8
+ * rather than growing its own queries over the same three tables.
9
+ *
10
+ * Route order matters: `webhooks/:id/deliveries` is registered **before**
11
+ * `webhooks/:id`, so the literal segment is matched before the parameter that
12
+ * would otherwise swallow it. The same reason content registers `bulk` ahead of
13
+ * `:id`.
14
+ */
15
+ export declare class WebhooksModule {
16
+ static forRoot(config?: WebhooksPluginConfig): DynamicModule;
17
+ }
18
+ //# sourceMappingURL=webhooks.module.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"webhooks.module.d.ts","sourceRoot":"","sources":["../../src/lib/webhooks.module.ts"],"names":[],"mappings":"AAAA,OAAO,EAAU,KAAK,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAY5D,OAAO,EAEH,KAAK,oBAAoB,EAC5B,MAAM,yBAAyB,CAAC;AAEjC;;;;;;;;;;;GAWG;AACH,qBACa,cAAc;IACvB,MAAM,CAAC,OAAO,CAAC,MAAM,GAAE,oBAAyB,GAAG,aAAa;CAoCnE"}
@@ -0,0 +1,72 @@
1
+ "use strict";
2
+ var WebhooksModule_1;
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.WebhooksModule = void 0;
5
+ const tslib_1 = require("tslib");
6
+ const common_1 = require("@nestjs/common");
7
+ const webhooks_tokens_1 = require("./webhooks.tokens");
8
+ const webhook_endpoints_service_1 = require("./application/webhook-endpoints.service");
9
+ const webhook_delivery_repository_1 = require("./infrastructure/webhook-delivery.repository");
10
+ const webhook_delivery_worker_1 = require("./infrastructure/webhook-delivery.worker");
11
+ const webhook_endpoint_repository_1 = require("./infrastructure/webhook-endpoint.repository");
12
+ const webhook_fanout_subscriber_1 = require("./infrastructure/webhook-fanout.subscriber");
13
+ const webhook_http_client_1 = require("./infrastructure/webhook-http.client");
14
+ const webhook_workspaces_purger_1 = require("./infrastructure/purge/webhook-workspaces.purger");
15
+ const webhook_deliveries_controller_1 = require("./http/controllers/webhook-deliveries.controller");
16
+ const webhook_endpoints_controller_1 = require("./http/controllers/webhook-endpoints.controller");
17
+ const webhook_events_controller_1 = require("./http/controllers/webhook-events.controller");
18
+ const webhooks_config_1 = require("./types/webhooks-config");
19
+ /**
20
+ * The webhooks plugin's one dynamic module.
21
+ *
22
+ * `global: true` like every other plugin here, so a future surface — an MCP
23
+ * tool that lists endpoints, a copilot read tool — injects these services
24
+ * rather than growing its own queries over the same three tables.
25
+ *
26
+ * Route order matters: `webhooks/:id/deliveries` is registered **before**
27
+ * `webhooks/:id`, so the literal segment is matched before the parameter that
28
+ * would otherwise swallow it. The same reason content registers `bulk` ahead of
29
+ * `:id`.
30
+ */
31
+ let WebhooksModule = WebhooksModule_1 = class WebhooksModule {
32
+ static forRoot(config = {}) {
33
+ return {
34
+ module: WebhooksModule_1,
35
+ global: true,
36
+ controllers: [
37
+ webhook_events_controller_1.WebhookEventsController,
38
+ webhook_deliveries_controller_1.WebhookDeliveriesController,
39
+ webhook_endpoints_controller_1.WebhookEndpointsController
40
+ ],
41
+ providers: [
42
+ {
43
+ provide: webhooks_tokens_1.WEBHOOKS_CONFIG,
44
+ useValue: (0, webhooks_config_1.resolveWebhooksConfig)(config)
45
+ },
46
+ webhook_endpoint_repository_1.WebhookEndpointRepository,
47
+ webhook_delivery_repository_1.WebhookDeliveryRepository,
48
+ webhook_http_client_1.WebhookHttpClient,
49
+ webhook_endpoints_service_1.WebhookEndpointsService,
50
+ // Registers itself with the outbox dispatcher on bootstrap and
51
+ // does nothing but queue rows.
52
+ webhook_fanout_subscriber_1.WebhookFanoutSubscriber,
53
+ // Arms the sender. The only place this plugin touches the
54
+ // network, and it never holds a transaction while it does.
55
+ webhook_delivery_worker_1.WebhookDeliveryWorker,
56
+ // Registers itself with the workspace-delete registry, if the
57
+ // host mounted workspaces at all.
58
+ webhook_workspaces_purger_1.WebhookWorkspacesPurger
59
+ ],
60
+ exports: [
61
+ webhooks_tokens_1.WEBHOOKS_CONFIG,
62
+ webhook_endpoints_service_1.WebhookEndpointsService,
63
+ webhook_endpoint_repository_1.WebhookEndpointRepository,
64
+ webhook_delivery_repository_1.WebhookDeliveryRepository
65
+ ]
66
+ };
67
+ }
68
+ };
69
+ exports.WebhooksModule = WebhooksModule;
70
+ exports.WebhooksModule = WebhooksModule = WebhooksModule_1 = tslib_1.__decorate([
71
+ (0, common_1.Module)({})
72
+ ], WebhooksModule);
@@ -0,0 +1,5 @@
1
+ /** DI token for the plugin's resolved configuration. */
2
+ export declare const WEBHOOKS_CONFIG: unique symbol;
3
+ /** Injects the resolved webhooks configuration. */
4
+ export declare const InjectWebhooksConfig: () => ParameterDecorator;
5
+ //# sourceMappingURL=webhooks.tokens.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"webhooks.tokens.d.ts","sourceRoot":"","sources":["../../src/lib/webhooks.tokens.ts"],"names":[],"mappings":"AAEA,wDAAwD;AACxD,eAAO,MAAM,eAAe,eAA4B,CAAC;AAEzD,mDAAmD;AACnD,eAAO,MAAM,oBAAoB,QAAO,kBACb,CAAC"}
@@ -0,0 +1,9 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.InjectWebhooksConfig = exports.WEBHOOKS_CONFIG = void 0;
4
+ const common_1 = require("@nestjs/common");
5
+ /** DI token for the plugin's resolved configuration. */
6
+ exports.WEBHOOKS_CONFIG = Symbol('WEBHOOKS_CONFIG');
7
+ /** Injects the resolved webhooks configuration. */
8
+ const InjectWebhooksConfig = () => (0, common_1.Inject)(exports.WEBHOOKS_CONFIG);
9
+ exports.InjectWebhooksConfig = InjectWebhooksConfig;
@@ -0,0 +1,53 @@
1
+ CREATE TABLE "webhook_deliveries" (
2
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
3
+ "endpoint_id" uuid NOT NULL,
4
+ "event_id" uuid NOT NULL,
5
+ "event_kind" text NOT NULL,
6
+ "workspace_id" uuid,
7
+ "content_type" text,
8
+ "payload" jsonb NOT NULL,
9
+ "status" text DEFAULT 'pending' NOT NULL,
10
+ "attempts" integer DEFAULT 0 NOT NULL,
11
+ "next_attempt_at" timestamp with time zone,
12
+ "claimed_at" timestamp with time zone,
13
+ "last_status_code" integer,
14
+ "last_error" text,
15
+ "response_snippet" text,
16
+ "duration_ms" integer,
17
+ "redelivery_of" uuid,
18
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
19
+ "completed_at" timestamp with time zone
20
+ );
21
+ --> statement-breakpoint
22
+ CREATE TABLE "webhook_endpoint_workspaces" (
23
+ "endpoint_id" uuid NOT NULL,
24
+ "workspace_id" uuid NOT NULL,
25
+ CONSTRAINT "webhook_endpoint_workspaces_endpoint_id_workspace_id_pk" PRIMARY KEY("endpoint_id","workspace_id")
26
+ );
27
+ --> statement-breakpoint
28
+ CREATE TABLE "webhook_endpoints" (
29
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
30
+ "name" text NOT NULL,
31
+ "url" text NOT NULL,
32
+ "secret" text NOT NULL,
33
+ "secret_hint" text NOT NULL,
34
+ "enabled" boolean DEFAULT true NOT NULL,
35
+ "event_kinds" jsonb DEFAULT '[]'::jsonb NOT NULL,
36
+ "content_types" jsonb DEFAULT '[]'::jsonb NOT NULL,
37
+ "all_workspaces" boolean DEFAULT false NOT NULL,
38
+ "headers" jsonb DEFAULT '{}'::jsonb NOT NULL,
39
+ "disabled_reason" text,
40
+ "consecutive_failures" integer DEFAULT 0 NOT NULL,
41
+ "created_by" uuid,
42
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
43
+ "updated_at" timestamp with time zone DEFAULT now() NOT NULL
44
+ );
45
+ --> statement-breakpoint
46
+ ALTER TABLE "webhook_deliveries" ADD CONSTRAINT "webhook_deliveries_endpoint_id_webhook_endpoints_id_fk" FOREIGN KEY ("endpoint_id") REFERENCES "public"."webhook_endpoints"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
47
+ ALTER TABLE "webhook_endpoint_workspaces" ADD CONSTRAINT "webhook_endpoint_workspaces_endpoint_id_webhook_endpoints_id_fk" FOREIGN KEY ("endpoint_id") REFERENCES "public"."webhook_endpoints"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
48
+ CREATE UNIQUE INDEX "webhook_deliveries_endpoint_event_unique" ON "webhook_deliveries" USING btree ("endpoint_id","event_id") WHERE "webhook_deliveries"."redelivery_of" is null;--> statement-breakpoint
49
+ CREATE INDEX "webhook_deliveries_claimable_idx" ON "webhook_deliveries" USING btree ("next_attempt_at") WHERE "webhook_deliveries"."status" in ('pending', 'failed');--> statement-breakpoint
50
+ CREATE INDEX "webhook_deliveries_endpoint_idx" ON "webhook_deliveries" USING btree ("endpoint_id","created_at");--> statement-breakpoint
51
+ CREATE INDEX "webhook_deliveries_completed_idx" ON "webhook_deliveries" USING btree ("completed_at");--> statement-breakpoint
52
+ CREATE INDEX "webhook_endpoint_workspaces_workspace_idx" ON "webhook_endpoint_workspaces" USING btree ("workspace_id");--> statement-breakpoint
53
+ CREATE INDEX "webhook_endpoints_enabled_idx" ON "webhook_endpoints" USING btree ("enabled");