@endora-commerce/mod-webhooks 0.100.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 +59 -0
- package/dist/admin/index.d.ts +34 -0
- package/dist/admin/index.d.ts.map +1 -0
- package/dist/admin/index.js +45 -0
- package/dist/admin/index.js.map +1 -0
- package/dist/admin/pages/WebhooksPage.d.ts +3 -0
- package/dist/admin/pages/WebhooksPage.d.ts.map +1 -0
- package/dist/admin/pages/WebhooksPage.js +220 -0
- package/dist/admin/pages/WebhooksPage.js.map +1 -0
- package/dist/backend/entities/webhook-delivery.entity.d.ts +29 -0
- package/dist/backend/entities/webhook-delivery.entity.d.ts.map +1 -0
- package/dist/backend/entities/webhook-delivery.entity.js +108 -0
- package/dist/backend/entities/webhook-delivery.entity.js.map +1 -0
- package/dist/backend/entities/webhook.entity.d.ts +33 -0
- package/dist/backend/entities/webhook.entity.d.ts.map +1 -0
- package/dist/backend/entities/webhook.entity.js +91 -0
- package/dist/backend/entities/webhook.entity.js.map +1 -0
- package/dist/backend/index.d.ts +85 -0
- package/dist/backend/index.d.ts.map +1 -0
- package/dist/backend/index.js +129 -0
- package/dist/backend/index.js.map +1 -0
- package/dist/backend/routes.d.ts +26 -0
- package/dist/backend/routes.d.ts.map +1 -0
- package/dist/backend/routes.js +84 -0
- package/dist/backend/routes.js.map +1 -0
- package/dist/backend/services/event-bridge.d.ts +54 -0
- package/dist/backend/services/event-bridge.d.ts.map +1 -0
- package/dist/backend/services/event-bridge.js +43 -0
- package/dist/backend/services/event-bridge.js.map +1 -0
- package/dist/backend/services/webhook-delivery-worker.d.ts +34 -0
- package/dist/backend/services/webhook-delivery-worker.d.ts.map +1 -0
- package/dist/backend/services/webhook-delivery-worker.js +70 -0
- package/dist/backend/services/webhook-delivery-worker.js.map +1 -0
- package/dist/backend/services/webhook-queue.d.ts +24 -0
- package/dist/backend/services/webhook-queue.d.ts.map +1 -0
- package/dist/backend/services/webhook-queue.js +31 -0
- package/dist/backend/services/webhook-queue.js.map +1 -0
- package/dist/backend/services/webhook-service.d.ts +82 -0
- package/dist/backend/services/webhook-service.d.ts.map +1 -0
- package/dist/backend/services/webhook-service.js +176 -0
- package/dist/backend/services/webhook-service.js.map +1 -0
- package/dist/manifest.d.ts +169 -0
- package/dist/manifest.d.ts.map +1 -0
- package/dist/manifest.js +122 -0
- package/dist/manifest.js.map +1 -0
- package/dist/migrations/20260425T091359_webhooks_us7_init.d.ts +22 -0
- package/dist/migrations/20260425T091359_webhooks_us7_init.d.ts.map +1 -0
- package/dist/migrations/20260425T091359_webhooks_us7_init.js +86 -0
- package/dist/migrations/20260425T091359_webhooks_us7_init.js.map +1 -0
- package/dist/migrations/20260724T203140_webhooks_org_filter.d.ts +17 -0
- package/dist/migrations/20260724T203140_webhooks_org_filter.d.ts.map +1 -0
- package/dist/migrations/20260724T203140_webhooks_org_filter.js +30 -0
- package/dist/migrations/20260724T203140_webhooks_org_filter.js.map +1 -0
- package/dist/migrations/20260727T200555_webhooks_drop_external_integrations.d.ts +28 -0
- package/dist/migrations/20260727T200555_webhooks_drop_external_integrations.d.ts.map +1 -0
- package/dist/migrations/20260727T200555_webhooks_drop_external_integrations.js +49 -0
- package/dist/migrations/20260727T200555_webhooks_drop_external_integrations.js.map +1 -0
- package/dist/migrations/index.d.ts +29 -0
- package/dist/migrations/index.d.ts.map +1 -0
- package/dist/migrations/index.js +33 -0
- package/dist/migrations/index.js.map +1 -0
- package/docs/webhooks.md +50 -0
- package/i18n/en.json +6 -0
- package/i18n/pl.json +6 -0
- package/package.json +97 -0
- package/tailwind.css +14 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
2
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
3
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
4
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
5
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
6
|
+
};
|
|
7
|
+
var __metadata = (this && this.__metadata) || function (k, v) {
|
|
8
|
+
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
9
|
+
};
|
|
10
|
+
import { Entity, Index, OptionalProps, PrimaryKey, Property } from '@mikro-orm/core';
|
|
11
|
+
import { GlobalEntity } from '@endora-commerce/platform/tenancy';
|
|
12
|
+
import { randomUUID } from 'crypto';
|
|
13
|
+
/**
|
|
14
|
+
* Outbound webhook subscription (FR-121).
|
|
15
|
+
*
|
|
16
|
+
* - `eventTypes` array gates which events trigger a delivery for this row.
|
|
17
|
+
* - `secret` is shared with the receiver so they can verify the HMAC the
|
|
18
|
+
* delivery worker stamps on every POST body.
|
|
19
|
+
* - `status='paused'` stops new deliveries without deleting historical
|
|
20
|
+
* WebhookDelivery rows.
|
|
21
|
+
*/
|
|
22
|
+
let Webhook = class Webhook {
|
|
23
|
+
[OptionalProps];
|
|
24
|
+
id = randomUUID();
|
|
25
|
+
name;
|
|
26
|
+
url;
|
|
27
|
+
/** Shared secret, stored plaintext (already at rest in the DB; the HMAC is
|
|
28
|
+
* what protects payload integrity in transit). 64 hex chars by default. */
|
|
29
|
+
secret;
|
|
30
|
+
eventTypes = [];
|
|
31
|
+
status = 'active';
|
|
32
|
+
createdByAdminUserId;
|
|
33
|
+
/**
|
|
34
|
+
* Feature 062 — delivery filter (data-model.md §3). NULL = platform-wide
|
|
35
|
+
* subscription (legacy semantics); a value restricts delivery to events
|
|
36
|
+
* whose payload `organizationId` matches. Evaluated in the event-bridge
|
|
37
|
+
* lookup; the row itself stays `@GlobalEntity()` (admin-managed platform
|
|
38
|
+
* configuration — research §R12).
|
|
39
|
+
*/
|
|
40
|
+
organizationId;
|
|
41
|
+
createdAt = new Date();
|
|
42
|
+
updatedAt = new Date();
|
|
43
|
+
};
|
|
44
|
+
__decorate([
|
|
45
|
+
PrimaryKey({ type: 'uuid' }),
|
|
46
|
+
__metadata("design:type", String)
|
|
47
|
+
], Webhook.prototype, "id", void 0);
|
|
48
|
+
__decorate([
|
|
49
|
+
Property({ type: 'string', length: 160 }),
|
|
50
|
+
__metadata("design:type", String)
|
|
51
|
+
], Webhook.prototype, "name", void 0);
|
|
52
|
+
__decorate([
|
|
53
|
+
Property({ type: 'string', length: 2048 }),
|
|
54
|
+
__metadata("design:type", String)
|
|
55
|
+
], Webhook.prototype, "url", void 0);
|
|
56
|
+
__decorate([
|
|
57
|
+
Property({ type: 'string', length: 128 }),
|
|
58
|
+
__metadata("design:type", String)
|
|
59
|
+
], Webhook.prototype, "secret", void 0);
|
|
60
|
+
__decorate([
|
|
61
|
+
Property({ type: 'json' }),
|
|
62
|
+
__metadata("design:type", Array)
|
|
63
|
+
], Webhook.prototype, "eventTypes", void 0);
|
|
64
|
+
__decorate([
|
|
65
|
+
Property({ type: 'string', length: 16 }),
|
|
66
|
+
Index(),
|
|
67
|
+
__metadata("design:type", String)
|
|
68
|
+
], Webhook.prototype, "status", void 0);
|
|
69
|
+
__decorate([
|
|
70
|
+
Property({ type: 'uuid', nullable: true }),
|
|
71
|
+
__metadata("design:type", Object)
|
|
72
|
+
], Webhook.prototype, "createdByAdminUserId", void 0);
|
|
73
|
+
__decorate([
|
|
74
|
+
Property({ type: 'uuid', nullable: true }),
|
|
75
|
+
Index(),
|
|
76
|
+
__metadata("design:type", Object)
|
|
77
|
+
], Webhook.prototype, "organizationId", void 0);
|
|
78
|
+
__decorate([
|
|
79
|
+
Property({ type: 'datetime', onCreate: () => new Date() }),
|
|
80
|
+
__metadata("design:type", Date)
|
|
81
|
+
], Webhook.prototype, "createdAt", void 0);
|
|
82
|
+
__decorate([
|
|
83
|
+
Property({ type: 'datetime', onUpdate: () => new Date() }),
|
|
84
|
+
__metadata("design:type", Date)
|
|
85
|
+
], Webhook.prototype, "updatedAt", void 0);
|
|
86
|
+
Webhook = __decorate([
|
|
87
|
+
GlobalEntity(),
|
|
88
|
+
Entity({ tableName: 'webhooks' })
|
|
89
|
+
], Webhook);
|
|
90
|
+
export { Webhook };
|
|
91
|
+
//# sourceMappingURL=webhook.entity.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"webhook.entity.js","sourceRoot":"","sources":["../../../src/backend/entities/webhook.entity.ts"],"names":[],"mappings":";;;;;;;;;AAAA,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,aAAa,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,iBAAiB,CAAC;AACrF,OAAO,EAAE,YAAY,EAAE,MAAM,mCAAmC,CAAC;AACjE,OAAO,EAAE,UAAU,EAAE,MAAM,QAAQ,CAAC;AAEpC;;;;;;;;GAQG;AAGI,IAAM,OAAO,GAAb,MAAM,OAAO;IAClB,CAAC,aAAa,CAAC,CAOM;IAGrB,EAAE,GAAW,UAAU,EAAE,CAAC;IAG1B,IAAI,CAAU;IAGd,GAAG,CAAU;IAEb;gFAC4E;IAE5E,MAAM,CAAU;IAGhB,UAAU,GAAa,EAAE,CAAC;IAI1B,MAAM,GAAwB,QAAQ,CAAC;IAGvC,oBAAoB,CAAiB;IAErC;;;;;;OAMG;IAGH,cAAc,CAAiB;IAG/B,SAAS,GAAS,IAAI,IAAI,EAAE,CAAC;IAG7B,SAAS,GAAS,IAAI,IAAI,EAAE,CAAC;CAC9B,CAAA;AAvCC;IADC,UAAU,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,CAAC;;mCACH;AAG1B;IADC,QAAQ,CAAC,EAAE,IAAI,EAAE,QAAQ,EAAE,MAAM,EAAE,GAAG,EAAE,CAAC;;qCAC5B;AAGd;IADC,QAAQ,CAAC,EAAE,IAAI,EAAE,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC;;oCAC9B;AAKb;IADC,QAAQ,CAAC,EAAE,IAAI,EAAE,QAAQ,EAAE,MAAM,EAAE,GAAG,EAAE,CAAC;;uCAC1B;AAGhB;IADC,QAAQ,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,CAAC;;2CACD;AAI1B;IAFC,QAAQ,CAAC,EAAE,IAAI,EAAE,QAAQ,EAAE,MAAM,EAAE,EAAE,EAAE,CAAC;IACxC,KAAK,EAAE;;uCAC+B;AAGvC;IADC,QAAQ,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC;;qDACN;AAWrC;IAFC,QAAQ,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC;IAC1C,KAAK,EAAE;;+CACuB;AAG/B;IADC,QAAQ,CAAC,EAAE,IAAI,EAAE,UAAU,EAAE,QAAQ,EAAE,GAAG,EAAE,CAAC,IAAI,IAAI,EAAE,EAAE,CAAC;8BAChD,IAAI;0CAAc;AAG7B;IADC,QAAQ,CAAC,EAAE,IAAI,EAAE,UAAU,EAAE,QAAQ,EAAE,GAAG,EAAE,CAAC,IAAI,IAAI,EAAE,EAAE,CAAC;8BAChD,IAAI;0CAAc;AAjDlB,OAAO;IAFnB,YAAY,EAAE;IACd,MAAM,CAAC,EAAE,SAAS,EAAE,UAAU,EAAE,CAAC;GACrB,OAAO,CAkDnB"}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { FastifyRequest } from 'fastify';
|
|
2
|
+
import type { Queue, Worker } from 'bullmq';
|
|
3
|
+
import type { EntityManager } from '@mikro-orm/postgresql';
|
|
4
|
+
import type { AuditPort } from '@endora-commerce/platform/kernel';
|
|
5
|
+
import { type ModuleContext } from '@endora-commerce/platform/kernel';
|
|
6
|
+
import type { RequireAdminFactory } from '@endora-commerce/platform/kernel';
|
|
7
|
+
import { WebhookService } from './services/webhook-service.js';
|
|
8
|
+
import { type WebhookJobData } from './services/webhook-queue.js';
|
|
9
|
+
import { WebhookDelivery } from './entities/webhook-delivery.entity.js';
|
|
10
|
+
import { Webhook } from './entities/webhook.entity.js';
|
|
11
|
+
/**
|
|
12
|
+
* `webhooks` — the module whose subscriptions are rows, not registrations
|
|
13
|
+
* (feature 072, wave 1, T098).
|
|
14
|
+
*
|
|
15
|
+
* `api_keys/plugin.ts` built the `WebhookService` and mounted the admin routes;
|
|
16
|
+
* `composition.ts` created the queue, wired the EventBus bridge and started the
|
|
17
|
+
* delivery worker. Three files for one module, none of them its own.
|
|
18
|
+
*
|
|
19
|
+
* **The gating question this module poses is the interesting part.** A webhook
|
|
20
|
+
* subscription is a database row an operator creates at runtime, so there is no
|
|
21
|
+
* registration seam per subscription to wrap — the obvious reading of
|
|
22
|
+
* Constitution XVII ("gate every subscription") has nothing to attach to. But
|
|
23
|
+
* the module owns exactly **two** EventBus subscriptions, one per bridged event
|
|
24
|
+
* type, and every delivery in the platform passes through them. Gating the
|
|
25
|
+
* bridge stops all of it at one seam, and does so without consulting the
|
|
26
|
+
* database: while the module is off, the subscription lookup is never reached,
|
|
27
|
+
* so no row is read and no job is enqueued.
|
|
28
|
+
*
|
|
29
|
+
* That also gives the right off-state semantics. Switching `webhooks` off is
|
|
30
|
+
* not uninstalling: the subscriptions stay in the table, deliveries already
|
|
31
|
+
* recorded stay in `webhook_deliveries`, and switching it back on resumes
|
|
32
|
+
* bridging without the operator re-creating anything. Events emitted while it
|
|
33
|
+
* was off are simply not delivered, which is what "behaves as if never
|
|
34
|
+
* installed" means for a module whose job is to react to events.
|
|
35
|
+
*
|
|
36
|
+
* **The delivery consumer came home in T143a cluster 6.** T098 left it in
|
|
37
|
+
* `composition.ts` on the grounds that "whether workers run at all is a
|
|
38
|
+
* deployment decision" — true of the *flag*, and not of the worker. Built by a
|
|
39
|
+
* root it was the one part of this module no lifecycle seam covered: the
|
|
40
|
+
* bridge stopped enqueuing when the module went off, while the consumer kept
|
|
41
|
+
* draining whatever was already on the queue and kept writing
|
|
42
|
+
* `webhook_deliveries` rows. `ctx.worker` puts it behind `defineModuleWorker`,
|
|
43
|
+
* which pauses it with the module and resumes it with it. The deployment half
|
|
44
|
+
* stays a root's, as `webhooksRunWorkers` — the same shape `searchRunWorkers`
|
|
45
|
+
* already has, and for the same reason: the harness runs no consumer, and
|
|
46
|
+
* deriving that from `BACKEND_ROLE` here would start one in every test file.
|
|
47
|
+
*/
|
|
48
|
+
/** The events bridged to the delivery queue (contracts/order-webhooks.md §2). */
|
|
49
|
+
export declare const BRIDGED_EVENT_TYPES: readonly ["order.created.v1", "order.status_changed.v1"];
|
|
50
|
+
export interface WebhooksCradle {
|
|
51
|
+
readonly emFactory: () => EntityManager;
|
|
52
|
+
readonly auditLogService: AuditPort;
|
|
53
|
+
readonly requireAdmin: RequireAdminFactory;
|
|
54
|
+
readonly redis: import('ioredis').Redis;
|
|
55
|
+
/**
|
|
56
|
+
* Whether this composition runs the delivery consumer (Principle X).
|
|
57
|
+
* Root-supplied: production follows `BACKEND_ROLE`, the harness runs none.
|
|
58
|
+
*/
|
|
59
|
+
readonly webhooksRunWorkers: boolean;
|
|
60
|
+
readonly webhookService: WebhookService;
|
|
61
|
+
readonly webhookQueue: Queue<WebhookJobData>;
|
|
62
|
+
readonly webhookDeliveryWorker: Worker<WebhookJobData>;
|
|
63
|
+
/**
|
|
64
|
+
* Who the acting admin is, from the production actor (feature 080, T051).
|
|
65
|
+
* Root-supplied, like the other request resolvers; both roots answer it from
|
|
66
|
+
* `request.actor` and throw 401 for a non-admin.
|
|
67
|
+
*/
|
|
68
|
+
readonly adminContextResolver: (req: FastifyRequest) => {
|
|
69
|
+
adminUserId: string;
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
export declare function registerModule(ctx: ModuleContext): void;
|
|
73
|
+
/**
|
|
74
|
+
* The module's persisted entity classes, on the `./backend` subpath, as one
|
|
75
|
+
* array and **no named class export** (D-168).
|
|
76
|
+
*
|
|
77
|
+
* This is the shape the platform reads when the package is *installed*: the
|
|
78
|
+
* boot-time loader (`src/packages/package-runtime.ts`, `exported['entities']`)
|
|
79
|
+
* and the static declaration reader (`scripts/lib/package-declarations.ts`),
|
|
80
|
+
* which is the third source of `check:module-boundary`'s `table→owner` map and
|
|
81
|
+
* the package pass of `check-entity-tenant-classification`. A missing array is
|
|
82
|
+
* answered with `[]` — zero entities registered, no error anywhere.
|
|
83
|
+
*/
|
|
84
|
+
export declare const entities: (typeof Webhook | typeof WebhookDelivery)[];
|
|
85
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/backend/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AAC9C,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAC5C,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AAC3D,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,kCAAkC,CAAC;AAClE,OAAO,EAAY,KAAK,aAAa,EAAE,MAAM,kCAAkC,CAAC;AAChF,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,kCAAkC,CAAC;AAG5E,OAAO,EAAE,cAAc,EAAE,MAAM,+BAA+B,CAAC;AAC/D,OAAO,EAGL,KAAK,cAAc,EACpB,MAAM,6BAA6B,CAAC;AAErC,OAAO,EAAE,eAAe,EAAE,MAAM,uCAAuC,CAAC;AACxE,OAAO,EAAE,OAAO,EAAE,MAAM,8BAA8B,CAAC;AAEvD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AAEH,iFAAiF;AACjF,eAAO,MAAM,mBAAmB,0DAA2D,CAAC;AAE5F,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,SAAS,EAAE,MAAM,aAAa,CAAC;IACxC,QAAQ,CAAC,eAAe,EAAE,SAAS,CAAC;IACpC,QAAQ,CAAC,YAAY,EAAE,mBAAmB,CAAC;IAC3C,QAAQ,CAAC,KAAK,EAAE,OAAO,SAAS,EAAE,KAAK,CAAC;IACxC;;;OAGG;IACH,QAAQ,CAAC,kBAAkB,EAAE,OAAO,CAAC;IACrC,QAAQ,CAAC,cAAc,EAAE,cAAc,CAAC;IACxC,QAAQ,CAAC,YAAY,EAAE,KAAK,CAAC,cAAc,CAAC,CAAC;IAC7C,QAAQ,CAAC,qBAAqB,EAAE,MAAM,CAAC,cAAc,CAAC,CAAC;IACvD;;;;OAIG;IACH,QAAQ,CAAC,oBAAoB,EAAE,CAAC,GAAG,EAAE,cAAc,KAAK;QAAE,WAAW,EAAE,MAAM,CAAA;KAAE,CAAC;CACjF;AAED,wBAAgB,cAAc,CAAC,GAAG,EAAE,aAAa,GAAG,IAAI,CAmFvD;AAED;;;;;;;;;;GAUG;AACH,eAAO,MAAM,QAAQ,6CAGpB,CAAC"}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { lazyPort } from '@endora-commerce/platform/kernel';
|
|
2
|
+
import { registerWebhooksAdminRoutes } from './routes.js';
|
|
3
|
+
import { bridgeEventHandler } from './services/event-bridge.js';
|
|
4
|
+
import { WebhookService } from './services/webhook-service.js';
|
|
5
|
+
import { createWebhookQueue, createWebhookWorker, } from './services/webhook-queue.js';
|
|
6
|
+
import { createDeliveryProcessor } from './services/webhook-delivery-worker.js';
|
|
7
|
+
import { WebhookDelivery } from './entities/webhook-delivery.entity.js';
|
|
8
|
+
import { Webhook } from './entities/webhook.entity.js';
|
|
9
|
+
/**
|
|
10
|
+
* `webhooks` — the module whose subscriptions are rows, not registrations
|
|
11
|
+
* (feature 072, wave 1, T098).
|
|
12
|
+
*
|
|
13
|
+
* `api_keys/plugin.ts` built the `WebhookService` and mounted the admin routes;
|
|
14
|
+
* `composition.ts` created the queue, wired the EventBus bridge and started the
|
|
15
|
+
* delivery worker. Three files for one module, none of them its own.
|
|
16
|
+
*
|
|
17
|
+
* **The gating question this module poses is the interesting part.** A webhook
|
|
18
|
+
* subscription is a database row an operator creates at runtime, so there is no
|
|
19
|
+
* registration seam per subscription to wrap — the obvious reading of
|
|
20
|
+
* Constitution XVII ("gate every subscription") has nothing to attach to. But
|
|
21
|
+
* the module owns exactly **two** EventBus subscriptions, one per bridged event
|
|
22
|
+
* type, and every delivery in the platform passes through them. Gating the
|
|
23
|
+
* bridge stops all of it at one seam, and does so without consulting the
|
|
24
|
+
* database: while the module is off, the subscription lookup is never reached,
|
|
25
|
+
* so no row is read and no job is enqueued.
|
|
26
|
+
*
|
|
27
|
+
* That also gives the right off-state semantics. Switching `webhooks` off is
|
|
28
|
+
* not uninstalling: the subscriptions stay in the table, deliveries already
|
|
29
|
+
* recorded stay in `webhook_deliveries`, and switching it back on resumes
|
|
30
|
+
* bridging without the operator re-creating anything. Events emitted while it
|
|
31
|
+
* was off are simply not delivered, which is what "behaves as if never
|
|
32
|
+
* installed" means for a module whose job is to react to events.
|
|
33
|
+
*
|
|
34
|
+
* **The delivery consumer came home in T143a cluster 6.** T098 left it in
|
|
35
|
+
* `composition.ts` on the grounds that "whether workers run at all is a
|
|
36
|
+
* deployment decision" — true of the *flag*, and not of the worker. Built by a
|
|
37
|
+
* root it was the one part of this module no lifecycle seam covered: the
|
|
38
|
+
* bridge stopped enqueuing when the module went off, while the consumer kept
|
|
39
|
+
* draining whatever was already on the queue and kept writing
|
|
40
|
+
* `webhook_deliveries` rows. `ctx.worker` puts it behind `defineModuleWorker`,
|
|
41
|
+
* which pauses it with the module and resumes it with it. The deployment half
|
|
42
|
+
* stays a root's, as `webhooksRunWorkers` — the same shape `searchRunWorkers`
|
|
43
|
+
* already has, and for the same reason: the harness runs no consumer, and
|
|
44
|
+
* deriving that from `BACKEND_ROLE` here would start one in every test file.
|
|
45
|
+
*/
|
|
46
|
+
/** The events bridged to the delivery queue (contracts/order-webhooks.md §2). */
|
|
47
|
+
export const BRIDGED_EVENT_TYPES = ['order.created.v1', 'order.status_changed.v1'];
|
|
48
|
+
export function registerModule(ctx) {
|
|
49
|
+
ctx.di.providePort('webhookService', ctx
|
|
50
|
+
.asFunction(({ emFactory, auditLogService }) => new WebhookService(emFactory, auditLogService))
|
|
51
|
+
.singleton());
|
|
52
|
+
ctx.di.register({
|
|
53
|
+
webhookQueue: ctx
|
|
54
|
+
.asFunction(({ redis }) => createWebhookQueue(redis))
|
|
55
|
+
.singleton()
|
|
56
|
+
.disposer((queue) => queue.close().catch(() => undefined)),
|
|
57
|
+
/**
|
|
58
|
+
* The delivery consumer: HMAC signing, retries and the
|
|
59
|
+
* `webhook_deliveries` bookkeeping (T143a).
|
|
60
|
+
*
|
|
61
|
+
* A registration rather than a `new` inside the route body, so it carries
|
|
62
|
+
* a **disposer** exactly as the queue does — a composition that disposes
|
|
63
|
+
* its container drains the worker before the Redis sockets go, which is
|
|
64
|
+
* what the graceful shutdown in `composition.ts` used to spell by hand.
|
|
65
|
+
* Nothing resolves it unless `webhooksRunWorkers` is true, and a
|
|
66
|
+
* registration nobody resolves is never constructed, so a composition that
|
|
67
|
+
* runs no consumer pays nothing for this.
|
|
68
|
+
*/
|
|
69
|
+
webhookDeliveryWorker: ctx
|
|
70
|
+
.asFunction(({ redis }) => createWebhookWorker(redis, createDeliveryProcessor({
|
|
71
|
+
// Read per call rather than captured: `webhookService` is this
|
|
72
|
+
// module's own gated port, and a job draining mid-flight must
|
|
73
|
+
// still meet the gate.
|
|
74
|
+
recordDelivery: async (input) => {
|
|
75
|
+
await ctx.cradle().webhookService.recordDelivery(input);
|
|
76
|
+
},
|
|
77
|
+
})))
|
|
78
|
+
.singleton()
|
|
79
|
+
.disposer((worker) => worker.close().catch(() => undefined)),
|
|
80
|
+
});
|
|
81
|
+
for (const eventType of BRIDGED_EVENT_TYPES) {
|
|
82
|
+
// One `ctx.subscribe` per bridged type. The kernel's wrapper is what makes
|
|
83
|
+
// the module's effective state decide whether the handler runs at all, so
|
|
84
|
+
// the lookup below is never reached while the module is off.
|
|
85
|
+
ctx.subscribe(eventType, (payload) => {
|
|
86
|
+
const { webhookQueue, webhookService } = ctx.cradle();
|
|
87
|
+
return bridgeEventHandler(eventType, {
|
|
88
|
+
queue: webhookQueue,
|
|
89
|
+
subscriptionLookup: webhookService,
|
|
90
|
+
})(payload);
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
ctx.routes(async (app) => {
|
|
94
|
+
const { requireAdmin, webhooksRunWorkers } = ctx.cradle();
|
|
95
|
+
// `ctx.worker` wraps it in `defineModuleWorker`, so it starts paused when
|
|
96
|
+
// the module is off and the orchestrator pauses and resumes it as the
|
|
97
|
+
// operator flips the module. Attached here rather than at registration
|
|
98
|
+
// because that is where `app.log` exists — the same place
|
|
99
|
+
// `google_analytics` attaches its delivery consumer.
|
|
100
|
+
if (webhooksRunWorkers) {
|
|
101
|
+
ctx.worker(ctx.cradle().webhookDeliveryWorker, { logger: app.log });
|
|
102
|
+
}
|
|
103
|
+
await registerWebhooksAdminRoutes(app, {
|
|
104
|
+
// Lazily, even though the module owns this port: route *registration* runs
|
|
105
|
+
// inside `buildServer` whatever the module's effective state is, so
|
|
106
|
+
// destructuring the gate here would stop the next start instead of
|
|
107
|
+
// stopping the routes (D-40).
|
|
108
|
+
webhookService: lazyPort(ctx, 'webhookService'),
|
|
109
|
+
requireAdmin,
|
|
110
|
+
resolveAdminUserId: (request) => ctx.cradle().adminContextResolver(request).adminUserId,
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* The module's persisted entity classes, on the `./backend` subpath, as one
|
|
116
|
+
* array and **no named class export** (D-168).
|
|
117
|
+
*
|
|
118
|
+
* This is the shape the platform reads when the package is *installed*: the
|
|
119
|
+
* boot-time loader (`src/packages/package-runtime.ts`, `exported['entities']`)
|
|
120
|
+
* and the static declaration reader (`scripts/lib/package-declarations.ts`),
|
|
121
|
+
* which is the third source of `check:module-boundary`'s `table→owner` map and
|
|
122
|
+
* the package pass of `check-entity-tenant-classification`. A missing array is
|
|
123
|
+
* answered with `[]` — zero entities registered, no error anywhere.
|
|
124
|
+
*/
|
|
125
|
+
export const entities = [
|
|
126
|
+
WebhookDelivery,
|
|
127
|
+
Webhook,
|
|
128
|
+
];
|
|
129
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/backend/index.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { FastifyInstance, FastifyRequest } from 'fastify';
|
|
2
|
+
import type { WebhookService } from './services/webhook-service.js';
|
|
3
|
+
import type { RequireAdminFactory } from '@endora-commerce/platform/kernel';
|
|
4
|
+
export interface WebhooksAdminDeps {
|
|
5
|
+
webhookService: WebhookService;
|
|
6
|
+
requireAdmin: RequireAdminFactory;
|
|
7
|
+
/**
|
|
8
|
+
* The acting admin's id, resolved from the request the guard in front of
|
|
9
|
+
* these routes has already accepted.
|
|
10
|
+
*
|
|
11
|
+
* Injected rather than read here, because the read used to be
|
|
12
|
+
* `testAdminUserId(request)` — `http/test-actor-carrier`, which
|
|
13
|
+
* `contracts/host-package.md` §1.4j classifies **A**: the file exists to
|
|
14
|
+
* narrow this repository's test-harness Fastify augmentation, and an
|
|
15
|
+
* installed package has no relationship to that harness. It also answered
|
|
16
|
+
* `undefined` in production for every request, because nothing under `src/`
|
|
17
|
+
* writes `request.testActor`.
|
|
18
|
+
*
|
|
19
|
+
* Both composition roots supply it from `adminContextResolver`, which reads
|
|
20
|
+
* the production actor and throws 401 for a non-admin. Every call site sits
|
|
21
|
+
* behind `requireAdmin(...)`, so the actor is an admin by the time it runs.
|
|
22
|
+
*/
|
|
23
|
+
resolveAdminUserId: (request: FastifyRequest) => string;
|
|
24
|
+
}
|
|
25
|
+
export declare function registerWebhooksAdminRoutes(app: FastifyInstance, deps: WebhooksAdminDeps): Promise<void>;
|
|
26
|
+
//# sourceMappingURL=routes.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"routes.d.ts","sourceRoot":"","sources":["../../src/backend/routes.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AAE/D,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,+BAA+B,CAAC;AAGpE,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,kCAAkC,CAAC;AAE5E,MAAM,WAAW,iBAAiB;IAChC,cAAc,EAAE,cAAc,CAAC;IAC/B,YAAY,EAAE,mBAAmB,CAAC;IAClC;;;;;;;;;;;;;;;OAeG;IACH,kBAAkB,EAAE,CAAC,OAAO,EAAE,cAAc,KAAK,MAAM,CAAC;CACzD;AAED,wBAAsB,2BAA2B,CAC/C,GAAG,EAAE,eAAe,EACpB,IAAI,EAAE,iBAAiB,GACtB,OAAO,CAAC,IAAI,CAAC,CAmFf"}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { createWebhookRequestSchema, updateWebhookRequestSchema } from '@endora-commerce/contracts';
|
|
2
|
+
export async function registerWebhooksAdminRoutes(app, deps) {
|
|
3
|
+
const { webhookService, requireAdmin, resolveAdminUserId } = deps;
|
|
4
|
+
app.get('/api/v1/admin/webhooks', { preHandler: requireAdmin('integrations:manage') }, async () => {
|
|
5
|
+
const rows = await webhookService.list();
|
|
6
|
+
return { data: rows.map(serializeWebhook) };
|
|
7
|
+
});
|
|
8
|
+
app.post('/api/v1/admin/webhooks', {
|
|
9
|
+
preHandler: requireAdmin('integrations:manage'),
|
|
10
|
+
schema: { body: createWebhookRequestSchema },
|
|
11
|
+
}, async (request, reply) => {
|
|
12
|
+
const body = createWebhookRequestSchema.parse(request.body);
|
|
13
|
+
const w = await webhookService.create({
|
|
14
|
+
name: body.name,
|
|
15
|
+
url: body.url,
|
|
16
|
+
eventTypes: body.eventTypes,
|
|
17
|
+
// Feature 062 — optional organization binding (additive).
|
|
18
|
+
organizationId: body.organizationId ?? null,
|
|
19
|
+
createdByAdminUserId: resolveAdminUserId(request),
|
|
20
|
+
});
|
|
21
|
+
reply.status(201);
|
|
22
|
+
// Reveal the signing secret once, on creation, so the operator can share
|
|
23
|
+
// it with the receiving system (it is never returned by the list/update
|
|
24
|
+
// endpoints again).
|
|
25
|
+
return { data: { ...serializeWebhook(w), secret: w.secret } };
|
|
26
|
+
});
|
|
27
|
+
app.patch('/api/v1/admin/webhooks/:id', {
|
|
28
|
+
preHandler: requireAdmin('integrations:manage'),
|
|
29
|
+
schema: { body: updateWebhookRequestSchema },
|
|
30
|
+
}, async (request) => {
|
|
31
|
+
const body = updateWebhookRequestSchema.parse(request.body);
|
|
32
|
+
const w = await webhookService.update(request.params.id, body);
|
|
33
|
+
return { data: serializeWebhook(w) };
|
|
34
|
+
});
|
|
35
|
+
app.delete('/api/v1/admin/webhooks/:id', { preHandler: requireAdmin('integrations:manage') }, async (request, reply) => {
|
|
36
|
+
await webhookService.remove(request.params.id);
|
|
37
|
+
return reply.status(204).send();
|
|
38
|
+
});
|
|
39
|
+
app.get('/api/v1/admin/webhooks/deliveries', { preHandler: requireAdmin('integrations:manage') }, async (request) => {
|
|
40
|
+
const { webhookId, status, limit } = request.query;
|
|
41
|
+
const rows = await webhookService.listDeliveries({
|
|
42
|
+
...(webhookId ? { webhookId } : {}),
|
|
43
|
+
...(status
|
|
44
|
+
? { status: status }
|
|
45
|
+
: {}),
|
|
46
|
+
...(limit ? { limit: Number(limit) } : {}),
|
|
47
|
+
});
|
|
48
|
+
return { data: rows.map(serializeDelivery) };
|
|
49
|
+
});
|
|
50
|
+
app.post('/api/v1/admin/webhooks/deliveries/:id/replay', { preHandler: requireAdmin('integrations:manage') }, async (request, reply) => {
|
|
51
|
+
const copy = await webhookService.replay(request.params.id);
|
|
52
|
+
reply.status(202);
|
|
53
|
+
return { data: serializeDelivery(copy) };
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
function serializeWebhook(w) {
|
|
57
|
+
return {
|
|
58
|
+
id: w.id,
|
|
59
|
+
name: w.name,
|
|
60
|
+
url: w.url,
|
|
61
|
+
eventTypes: w.eventTypes,
|
|
62
|
+
status: w.status,
|
|
63
|
+
organizationId: w.organizationId ?? null,
|
|
64
|
+
createdAt: w.createdAt.toISOString(),
|
|
65
|
+
updatedAt: w.updatedAt.toISOString(),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
function serializeDelivery(d) {
|
|
69
|
+
return {
|
|
70
|
+
id: d.id,
|
|
71
|
+
webhookId: d.webhookId,
|
|
72
|
+
eventId: d.eventId,
|
|
73
|
+
eventType: d.eventType,
|
|
74
|
+
status: d.status,
|
|
75
|
+
attemptCount: d.attemptCount,
|
|
76
|
+
dispatchedAt: d.dispatchedAt?.toISOString() ?? null,
|
|
77
|
+
completedAt: d.completedAt?.toISOString() ?? null,
|
|
78
|
+
deadLetteredAt: d.deadLetteredAt?.toISOString() ?? null,
|
|
79
|
+
lastResponseStatus: d.lastResponseStatus ?? null,
|
|
80
|
+
lastError: d.lastError ?? null,
|
|
81
|
+
createdAt: d.createdAt.toISOString(),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
//# sourceMappingURL=routes.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"routes.js","sourceRoot":"","sources":["../../src/backend/routes.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { Queue } from 'bullmq';
|
|
2
|
+
import type { EventBase } from '@endora-commerce/platform/events';
|
|
3
|
+
import type { WebhookJobData } from './webhook-queue.js';
|
|
4
|
+
/**
|
|
5
|
+
* Bridges the in-process event bus (R-17) to the durable webhook queue (R-13).
|
|
6
|
+
*
|
|
7
|
+
* On each emitted domain event:
|
|
8
|
+
* 1. Look up active Webhook subscriptions that match the event type.
|
|
9
|
+
* 2. For every subscriber, enqueue one BullMQ job on the webhook.deliver queue.
|
|
10
|
+
*
|
|
11
|
+
* The actual lookup of Webhook rows is injected; this keeps the bridge independent
|
|
12
|
+
* of MikroORM and easy to test.
|
|
13
|
+
*/
|
|
14
|
+
export interface SubscriptionLookup {
|
|
15
|
+
/**
|
|
16
|
+
* Feature 062 — org-scoped delivery (contracts/order-webhooks.md §2).
|
|
17
|
+
* `organizationId` is the value extracted from the event payload (or null
|
|
18
|
+
* when the payload carries none). Implementations return active
|
|
19
|
+
* subscriptions matching the event type where the subscription is
|
|
20
|
+
* platform-wide (`organizationId` NULL, legacy semantics — FR-015) OR bound
|
|
21
|
+
* to exactly this organization. An event without an organization must never
|
|
22
|
+
* reach an org-scoped subscription (fail closed — Principle XI).
|
|
23
|
+
*/
|
|
24
|
+
findActiveByEventType(eventType: string, organizationId: string | null): Promise<Array<{
|
|
25
|
+
webhookId: string;
|
|
26
|
+
url: string;
|
|
27
|
+
secret: string;
|
|
28
|
+
}>>;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Delivery-filter predicate shared by the subscription lookup: a subscription
|
|
32
|
+
* receives the event iff it is platform-wide, or the event carries an
|
|
33
|
+
* organization and it matches the subscription's binding.
|
|
34
|
+
*/
|
|
35
|
+
export declare function subscriptionReceivesOrganization(subscriptionOrganizationId: string | null, eventOrganizationId: string | null): boolean;
|
|
36
|
+
export interface EventBridgeOptions {
|
|
37
|
+
queue: Pick<Queue<WebhookJobData>, 'add'>;
|
|
38
|
+
subscriptionLookup: SubscriptionLookup;
|
|
39
|
+
}
|
|
40
|
+
export interface BridgedEventPayload extends EventBase {
|
|
41
|
+
[key: string]: unknown;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* The per-event-type half of the bridge, extracted so a converted module can
|
|
45
|
+
* hand it to `ctx.subscribe` (feature 072, T098).
|
|
46
|
+
*
|
|
47
|
+
* That is the whole gating story for this module. A webhook subscription is a
|
|
48
|
+
* database row created at runtime, so there is no registration seam per
|
|
49
|
+
* subscription to gate — but there are exactly **two** EventBus subscriptions,
|
|
50
|
+
* and gating those stops every delivery at once. Gate the bridge, not the
|
|
51
|
+
* subscriptions.
|
|
52
|
+
*/
|
|
53
|
+
export declare function bridgeEventHandler(eventType: string, deps: EventBridgeOptions): (payload: unknown) => Promise<void>;
|
|
54
|
+
//# sourceMappingURL=event-bridge.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"event-bridge.d.ts","sourceRoot":"","sources":["../../../src/backend/services/event-bridge.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,QAAQ,CAAC;AACpC,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,kCAAkC,CAAC;AAClE,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAC;AAEzD;;;;;;;;;GASG;AAEH,MAAM,WAAW,kBAAkB;IACjC;;;;;;;;OAQG;IACH,qBAAqB,CACnB,SAAS,EAAE,MAAM,EACjB,cAAc,EAAE,MAAM,GAAG,IAAI,GAC5B,OAAO,CAAC,KAAK,CAAC;QAAE,SAAS,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC,CAAC;CACvE;AAED;;;;GAIG;AACH,wBAAgB,gCAAgC,CAC9C,0BAA0B,EAAE,MAAM,GAAG,IAAI,EACzC,mBAAmB,EAAE,MAAM,GAAG,IAAI,GACjC,OAAO,CAGT;AAQD,MAAM,WAAW,kBAAkB;IACjC,KAAK,EAAE,IAAI,CAAC,KAAK,CAAC,cAAc,CAAC,EAAE,KAAK,CAAC,CAAC;IAC1C,kBAAkB,EAAE,kBAAkB,CAAC;CACxC;AAED,MAAM,WAAW,mBAAoB,SAAQ,SAAS;IAGpD,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACxB;AAED;;;;;;;;;GASG;AACH,wBAAgB,kBAAkB,CAChC,SAAS,EAAE,MAAM,EACjB,IAAI,EAAE,kBAAkB,GACvB,CAAC,OAAO,EAAE,OAAO,KAAK,OAAO,CAAC,IAAI,CAAC,CAkBrC"}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { randomUUID } from 'crypto';
|
|
2
|
+
/**
|
|
3
|
+
* Delivery-filter predicate shared by the subscription lookup: a subscription
|
|
4
|
+
* receives the event iff it is platform-wide, or the event carries an
|
|
5
|
+
* organization and it matches the subscription's binding.
|
|
6
|
+
*/
|
|
7
|
+
export function subscriptionReceivesOrganization(subscriptionOrganizationId, eventOrganizationId) {
|
|
8
|
+
if (subscriptionOrganizationId === null)
|
|
9
|
+
return true;
|
|
10
|
+
return eventOrganizationId !== null && subscriptionOrganizationId === eventOrganizationId;
|
|
11
|
+
}
|
|
12
|
+
/** Extract the tenant key from a bridged event payload; non-string values count as absent. */
|
|
13
|
+
function extractOrganizationId(payload) {
|
|
14
|
+
const value = payload['organizationId'];
|
|
15
|
+
return typeof value === 'string' && value.length > 0 ? value : null;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* The per-event-type half of the bridge, extracted so a converted module can
|
|
19
|
+
* hand it to `ctx.subscribe` (feature 072, T098).
|
|
20
|
+
*
|
|
21
|
+
* That is the whole gating story for this module. A webhook subscription is a
|
|
22
|
+
* database row created at runtime, so there is no registration seam per
|
|
23
|
+
* subscription to gate — but there are exactly **two** EventBus subscriptions,
|
|
24
|
+
* and gating those stops every delivery at once. Gate the bridge, not the
|
|
25
|
+
* subscriptions.
|
|
26
|
+
*/
|
|
27
|
+
export function bridgeEventHandler(eventType, deps) {
|
|
28
|
+
return async (payload) => {
|
|
29
|
+
const subs = await deps.subscriptionLookup.findActiveByEventType(eventType, extractOrganizationId(payload));
|
|
30
|
+
for (const sub of subs) {
|
|
31
|
+
const jobData = {
|
|
32
|
+
webhookId: sub.webhookId,
|
|
33
|
+
eventId: payload.eventId ?? randomUUID(),
|
|
34
|
+
eventType,
|
|
35
|
+
payload,
|
|
36
|
+
url: sub.url,
|
|
37
|
+
secret: sub.secret,
|
|
38
|
+
};
|
|
39
|
+
await deps.queue.add(`${eventType}.${jobData.eventId}`, jobData);
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
//# sourceMappingURL=event-bridge.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"event-bridge.js","sourceRoot":"","sources":["../../../src/backend/services/event-bridge.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,QAAQ,CAAC;AAgCpC;;;;GAIG;AACH,MAAM,UAAU,gCAAgC,CAC9C,0BAAyC,EACzC,mBAAkC;IAElC,IAAI,0BAA0B,KAAK,IAAI;QAAE,OAAO,IAAI,CAAC;IACrD,OAAO,mBAAmB,KAAK,IAAI,IAAI,0BAA0B,KAAK,mBAAmB,CAAC;AAC5F,CAAC;AAED,8FAA8F;AAC9F,SAAS,qBAAqB,CAAC,OAAgB;IAC7C,MAAM,KAAK,GAAI,OAAmC,CAAC,gBAAgB,CAAC,CAAC;IACrE,OAAO,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,CAAC;AACtE,CAAC;AAaD;;;;;;;;;GASG;AACH,MAAM,UAAU,kBAAkB,CAChC,SAAiB,EACjB,IAAwB;IAExB,OAAO,KAAK,EAAE,OAAO,EAAE,EAAE;QACvB,MAAM,IAAI,GAAG,MAAM,IAAI,CAAC,kBAAkB,CAAC,qBAAqB,CAC9D,SAAS,EACT,qBAAqB,CAAC,OAAO,CAAC,CAC/B,CAAC;QACF,KAAK,MAAM,GAAG,IAAI,IAAI,EAAE,CAAC;YACvB,MAAM,OAAO,GAAmB;gBAC9B,SAAS,EAAE,GAAG,CAAC,SAAS;gBACxB,OAAO,EAAG,OAA+B,CAAC,OAAO,IAAI,UAAU,EAAE;gBACjE,SAAS;gBACT,OAAO;gBACP,GAAG,EAAE,GAAG,CAAC,GAAG;gBACZ,MAAM,EAAE,GAAG,CAAC,MAAM;aACnB,CAAC;YACF,MAAM,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,GAAG,SAAS,IAAI,OAAO,CAAC,OAAO,EAAE,EAAE,OAAO,CAAC,CAAC;QACnE,CAAC;IACH,CAAC,CAAC;AACJ,CAAC"}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { Job } from 'bullmq';
|
|
2
|
+
import type { WebhookJobData } from './webhook-queue.js';
|
|
3
|
+
/**
|
|
4
|
+
* Worker-side processor: signs the payload with HMAC-SHA-256, POSTs it to the
|
|
5
|
+
* receiver URL, and throws on non-2xx so BullMQ retries (R-13).
|
|
6
|
+
*/
|
|
7
|
+
export interface DeliveryRecordInput {
|
|
8
|
+
webhookId: string;
|
|
9
|
+
eventId: string;
|
|
10
|
+
eventType: string;
|
|
11
|
+
payload: unknown;
|
|
12
|
+
status: 'succeeded' | 'failed' | 'dead_lettered';
|
|
13
|
+
attemptCount: number;
|
|
14
|
+
lastResponseStatus?: number;
|
|
15
|
+
lastError?: string;
|
|
16
|
+
}
|
|
17
|
+
export interface ProcessorDependencies {
|
|
18
|
+
/** Injectable for tests; defaults to globalThis.fetch at call time. */
|
|
19
|
+
fetchFn?: typeof fetch;
|
|
20
|
+
/** Per-attempt timeout in ms. */
|
|
21
|
+
timeoutMs?: number;
|
|
22
|
+
/** Logger for failed deliveries (pino.Logger compatible). */
|
|
23
|
+
onFailure?: (err: unknown, job: Job<WebhookJobData>) => void;
|
|
24
|
+
/**
|
|
25
|
+
* Feature 062 — persistence hook for the `webhook_deliveries` audit trail
|
|
26
|
+
* (typically `WebhookService.recordDelivery`). One row per attempt outcome:
|
|
27
|
+
* `succeeded` on 2xx, `failed` on a retryable attempt, `dead_lettered` on
|
|
28
|
+
* the final exhausted attempt. Best-effort: a bookkeeping failure never
|
|
29
|
+
* changes the delivery outcome.
|
|
30
|
+
*/
|
|
31
|
+
recordDelivery?: (input: DeliveryRecordInput) => Promise<unknown>;
|
|
32
|
+
}
|
|
33
|
+
export declare function createDeliveryProcessor(deps?: ProcessorDependencies): (job: Job<WebhookJobData>) => Promise<void>;
|
|
34
|
+
//# sourceMappingURL=webhook-delivery-worker.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"webhook-delivery-worker.d.ts","sourceRoot":"","sources":["../../../src/backend/services/webhook-delivery-worker.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,QAAQ,CAAC;AAClC,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAC;AAEzD;;;GAGG;AAEH,MAAM,WAAW,mBAAmB;IAClC,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,OAAO,CAAC;IACjB,MAAM,EAAE,WAAW,GAAG,QAAQ,GAAG,eAAe,CAAC;IACjD,YAAY,EAAE,MAAM,CAAC;IACrB,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,qBAAqB;IACpC,uEAAuE;IACvE,OAAO,CAAC,EAAE,OAAO,KAAK,CAAC;IACvB,iCAAiC;IACjC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,6DAA6D;IAC7D,SAAS,CAAC,EAAE,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,GAAG,CAAC,cAAc,CAAC,KAAK,IAAI,CAAC;IAC7D;;;;;;OAMG;IACH,cAAc,CAAC,EAAE,CAAC,KAAK,EAAE,mBAAmB,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;CACnE;AAED,wBAAgB,uBAAuB,CAAC,IAAI,GAAE,qBAA0B,IAYxC,KAAK,GAAG,CAAC,cAAc,CAAC,KAAG,OAAO,CAAC,IAAI,CAAC,CAsDvE"}
|