@nestarc/webhook 0.13.0 → 0.13.2
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/CHANGELOG.md +441 -0
- package/README.md +384 -367
- package/SECURITY.md +27 -0
- package/dist/adapters/prisma-delivery.repository.d.ts +1 -1
- package/dist/adapters/prisma-delivery.repository.d.ts.map +1 -1
- package/dist/adapters/prisma-delivery.repository.js +79 -18
- package/dist/adapters/prisma-delivery.repository.js.map +1 -1
- package/dist/adapters/prisma-event.repository.d.ts +2 -2
- package/dist/adapters/prisma-event.repository.d.ts.map +1 -1
- package/dist/adapters/prisma-event.repository.js +6 -6
- package/dist/adapters/prisma-event.repository.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/interfaces/webhook-delivery.interface.d.ts +4 -0
- package/dist/interfaces/webhook-delivery.interface.d.ts.map +1 -1
- package/dist/interfaces/webhook-endpoint.interface.d.ts +2 -2
- package/dist/interfaces/webhook-endpoint.interface.d.ts.map +1 -1
- package/dist/interfaces/webhook-options.interface.d.ts +2 -1
- package/dist/interfaces/webhook-options.interface.d.ts.map +1 -1
- package/dist/ports/webhook-delivery.repository.d.ts +9 -1
- package/dist/ports/webhook-delivery.repository.d.ts.map +1 -1
- package/dist/ports/webhook-event.repository.d.ts +7 -3
- package/dist/ports/webhook-event.repository.d.ts.map +1 -1
- package/dist/webhook.delivery-admin.service.d.ts +3 -1
- package/dist/webhook.delivery-admin.service.d.ts.map +1 -1
- package/dist/webhook.delivery-admin.service.js +7 -3
- package/dist/webhook.delivery-admin.service.js.map +1 -1
- package/dist/webhook.service.d.ts +13 -0
- package/dist/webhook.service.d.ts.map +1 -1
- package/dist/webhook.service.js +14 -1
- package/dist/webhook.service.js.map +1 -1
- package/docs/usage.md +109 -0
- package/examples/quick-start/README.md +83 -0
- package/examples/quick-start/main.ts +247 -0
- package/examples/quick-start/package.json +29 -0
- package/examples/quick-start/prisma/schema.prisma +10 -0
- package/examples/quick-start/prisma.config.ts +6 -0
- package/examples/quick-start/tsconfig.json +15 -0
- package/llms.txt +12 -0
- package/package.json +32 -13
|
@@ -17,8 +17,10 @@ const common_1 = require("@nestjs/common");
|
|
|
17
17
|
const webhook_constants_1 = require("./webhook.constants");
|
|
18
18
|
let WebhookDeliveryAdminService = class WebhookDeliveryAdminService {
|
|
19
19
|
deliveryRepo;
|
|
20
|
-
|
|
20
|
+
options;
|
|
21
|
+
constructor(deliveryRepo, options) {
|
|
21
22
|
this.deliveryRepo = deliveryRepo;
|
|
23
|
+
this.options = options;
|
|
22
24
|
}
|
|
23
25
|
async getDeliveryLogs(endpointId, filters) {
|
|
24
26
|
return this.deliveryRepo.getDeliveryLogs(endpointId, filters);
|
|
@@ -39,13 +41,15 @@ let WebhookDeliveryAdminService = class WebhookDeliveryAdminService {
|
|
|
39
41
|
if (!this.deliveryRepo.replayEvent) {
|
|
40
42
|
throw new Error('WebhookDeliveryRepository does not support event replay');
|
|
41
43
|
}
|
|
42
|
-
return this.deliveryRepo.replayEvent(eventId, options);
|
|
44
|
+
return this.deliveryRepo.replayEvent(eventId, options, this.options?.delivery?.maxRetries ?? webhook_constants_1.DEFAULT_MAX_RETRIES);
|
|
43
45
|
}
|
|
44
46
|
};
|
|
45
47
|
exports.WebhookDeliveryAdminService = WebhookDeliveryAdminService;
|
|
46
48
|
exports.WebhookDeliveryAdminService = WebhookDeliveryAdminService = __decorate([
|
|
47
49
|
(0, common_1.Injectable)(),
|
|
48
50
|
__param(0, (0, common_1.Inject)(webhook_constants_1.WEBHOOK_DELIVERY_REPOSITORY)),
|
|
49
|
-
|
|
51
|
+
__param(1, (0, common_1.Optional)()),
|
|
52
|
+
__param(1, (0, common_1.Inject)(webhook_constants_1.WEBHOOK_MODULE_OPTIONS)),
|
|
53
|
+
__metadata("design:paramtypes", [Object, Object])
|
|
50
54
|
], WebhookDeliveryAdminService);
|
|
51
55
|
//# sourceMappingURL=webhook.delivery-admin.service.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"webhook.delivery-admin.service.js","sourceRoot":"","sources":["../src/webhook.delivery-admin.service.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;AAAA,
|
|
1
|
+
{"version":3,"file":"webhook.delivery-admin.service.js","sourceRoot":"","sources":["../src/webhook.delivery-admin.service.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;AAAA,2CAA8D;AAC9D,2DAI6B;AAetB,IAAM,2BAA2B,GAAjC,MAAM,2BAA2B;IAGnB;IAGA;IALnB,YAEmB,YAAuC,EAGvC,OAA8B;QAH9B,iBAAY,GAAZ,YAAY,CAA2B;QAGvC,YAAO,GAAP,OAAO,CAAuB;IAC9C,CAAC;IAEJ,KAAK,CAAC,eAAe,CACnB,UAAkB,EAClB,OAA4B;QAE5B,OAAO,IAAI,CAAC,YAAY,CAAC,eAAe,CAAC,UAAU,EAAE,OAAO,CAAC,CAAC;IAChE,CAAC;IAED,KAAK,CAAC,mBAAmB,CAAC,UAAkB;QAC1C,OAAO,IAAI,CAAC,YAAY,CAAC,mBAAmB,CAAC,UAAU,CAAC,CAAC;IAC3D,CAAC;IAED,KAAK,CAAC,aAAa,CACjB,UAAkB,EAClB,OAA8B;QAE9B,OAAO,IAAI,CAAC,YAAY,CAAC,aAAa,CAAC,UAAU,EAAE,OAAO,CAAC,CAAC;IAC9D,CAAC;IAED,KAAK,CAAC,qBAAqB,CACzB,OAAqC,EACrC,OAA8B;QAE9B,IAAI,CAAC,IAAI,CAAC,YAAY,CAAC,qBAAqB,EAAE,CAAC;YAC7C,MAAM,IAAI,KAAK,CACb,uEAAuE,CACxE,CAAC;QACJ,CAAC;QAED,OAAO,IAAI,CAAC,YAAY,CAAC,qBAAqB,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC;IACnE,CAAC;IAED,KAAK,CAAC,WAAW,CACf,OAAe,EACf,OAA4B;QAE5B,IAAI,CAAC,IAAI,CAAC,YAAY,CAAC,WAAW,EAAE,CAAC;YACnC,MAAM,IAAI,KAAK,CAAC,yDAAyD,CAAC,CAAC;QAC7E,CAAC;QAED,OAAO,IAAI,CAAC,YAAY,CAAC,WAAW,CAClC,OAAO,EACP,OAAO,EACP,IAAI,CAAC,OAAO,EAAE,QAAQ,EAAE,UAAU,IAAI,uCAAmB,CAC1D,CAAC;IACJ,CAAC;CACF,CAAA;AAtDY,kEAA2B;sCAA3B,2BAA2B;IADvC,IAAA,mBAAU,GAAE;IAGR,WAAA,IAAA,eAAM,EAAC,+CAA2B,CAAC,CAAA;IAEnC,WAAA,IAAA,iBAAQ,GAAE,CAAA;IACV,WAAA,IAAA,eAAM,EAAC,0CAAsB,CAAC,CAAA;;GALtB,2BAA2B,CAsDvC"}
|
|
@@ -11,8 +11,21 @@ export declare class WebhookService {
|
|
|
11
11
|
private readonly maxAttempts;
|
|
12
12
|
private readonly redaction?;
|
|
13
13
|
constructor(eventRepo: WebhookEventRepository, endpointRepo: WebhookEndpointRepository, deliveryRepo: WebhookDeliveryRepository, options: WebhookModuleOptions);
|
|
14
|
+
/**
|
|
15
|
+
* Publish to active matching endpoints across all tenants.
|
|
16
|
+
* Returns the saved event ID even when no endpoints match. correlationId is
|
|
17
|
+
* stored independently of idempotencyKey; duplicate keys return the original
|
|
18
|
+
* event without creating deliveries or updating its metadata.
|
|
19
|
+
*/
|
|
14
20
|
send(event: WebhookEvent, options?: WebhookPublishOptions): Promise<string>;
|
|
21
|
+
/** Publish to active matching endpoints belonging to the specified tenant. */
|
|
15
22
|
sendToTenant(tenantId: string, event: WebhookEvent, options?: WebhookPublishOptions): Promise<string>;
|
|
23
|
+
/**
|
|
24
|
+
* Publish to eligible endpoints among the specified IDs. The built-in repository
|
|
25
|
+
* requires active endpoints subscribed to the exact event type or literal '*'.
|
|
26
|
+
* When a tenant ID is provided, endpoint ownership must match that tenant.
|
|
27
|
+
* Pass publish options as the third argument when no tenant ID is needed.
|
|
28
|
+
*/
|
|
16
29
|
sendToEndpoints(endpointIds: string[], event: WebhookEvent, tenantIdOrOptions?: string | WebhookPublishOptions, options?: WebhookPublishOptions): Promise<string>;
|
|
17
30
|
private sendInternal;
|
|
18
31
|
private saveEventInTransaction;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"webhook.service.d.ts","sourceRoot":"","sources":["../src/webhook.service.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAO/C,OAAO,EACL,oBAAoB,EACpB,qBAAqB,EAEtB,MAAM,wCAAwC,CAAC;AAChD,OAAO,EAAE,sBAAsB,EAAE,MAAM,kCAAkC,CAAC;AAC1E,OAAO,EAAE,yBAAyB,EAAE,MAAM,qCAAqC,CAAC;AAChF,OAAO,EACL,yBAAyB,EAE1B,MAAM,qCAAqC,CAAC;AAE7C,qBACa,cAAc;IAOvB,OAAO,CAAC,QAAQ,CAAC,SAAS;IAE1B,OAAO,CAAC,QAAQ,CAAC,YAAY;IAE7B,OAAO,CAAC,QAAQ,CAAC,YAAY;IAV/B,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAmC;IAC1D,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAS;IACrC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAC,CAA0B;gBAIlC,SAAS,EAAE,sBAAsB,EAEjC,YAAY,EAAE,yBAAyB,EAEvC,YAAY,EAAE,yBAAyB,EAExD,OAAO,EAAE,oBAAoB;
|
|
1
|
+
{"version":3,"file":"webhook.service.d.ts","sourceRoot":"","sources":["../src/webhook.service.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAO/C,OAAO,EACL,oBAAoB,EACpB,qBAAqB,EAEtB,MAAM,wCAAwC,CAAC;AAChD,OAAO,EAAE,sBAAsB,EAAE,MAAM,kCAAkC,CAAC;AAC1E,OAAO,EAAE,yBAAyB,EAAE,MAAM,qCAAqC,CAAC;AAChF,OAAO,EACL,yBAAyB,EAE1B,MAAM,qCAAqC,CAAC;AAE7C,qBACa,cAAc;IAOvB,OAAO,CAAC,QAAQ,CAAC,SAAS;IAE1B,OAAO,CAAC,QAAQ,CAAC,YAAY;IAE7B,OAAO,CAAC,QAAQ,CAAC,YAAY;IAV/B,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAmC;IAC1D,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAS;IACrC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAC,CAA0B;gBAIlC,SAAS,EAAE,sBAAsB,EAEjC,YAAY,EAAE,yBAAyB,EAEvC,YAAY,EAAE,yBAAyB,EAExD,OAAO,EAAE,oBAAoB;IAM/B;;;;;OAKG;IACG,IAAI,CAAC,KAAK,EAAE,YAAY,EAAE,OAAO,CAAC,EAAE,qBAAqB,GAAG,OAAO,CAAC,MAAM,CAAC;IAIjF,8EAA8E;IACxE,YAAY,CAChB,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,YAAY,EACnB,OAAO,CAAC,EAAE,qBAAqB,GAC9B,OAAO,CAAC,MAAM,CAAC;IAIlB;;;;;OAKG;IACG,eAAe,CACnB,WAAW,EAAE,MAAM,EAAE,EACrB,KAAK,EAAE,YAAY,EACnB,iBAAiB,CAAC,EAAE,MAAM,GAAG,qBAAqB,EAClD,OAAO,CAAC,EAAE,qBAAqB,GAC9B,OAAO,CAAC,MAAM,CAAC;YAmDJ,YAAY;YA2DZ,sBAAsB;IA8BpC,OAAO,CAAC,eAAe;CAOxB"}
|
package/dist/webhook.service.js
CHANGED
|
@@ -30,12 +30,25 @@ let WebhookService = WebhookService_1 = class WebhookService {
|
|
|
30
30
|
this.maxAttempts = options.delivery?.maxRetries ?? 5;
|
|
31
31
|
this.redaction = options.redaction;
|
|
32
32
|
}
|
|
33
|
+
/**
|
|
34
|
+
* Publish to active matching endpoints across all tenants.
|
|
35
|
+
* Returns the saved event ID even when no endpoints match. correlationId is
|
|
36
|
+
* stored independently of idempotencyKey; duplicate keys return the original
|
|
37
|
+
* event without creating deliveries or updating its metadata.
|
|
38
|
+
*/
|
|
33
39
|
async send(event, options) {
|
|
34
40
|
return this.sendInternal(event, undefined, options);
|
|
35
41
|
}
|
|
42
|
+
/** Publish to active matching endpoints belonging to the specified tenant. */
|
|
36
43
|
async sendToTenant(tenantId, event, options) {
|
|
37
44
|
return this.sendInternal(event, tenantId, options);
|
|
38
45
|
}
|
|
46
|
+
/**
|
|
47
|
+
* Publish to eligible endpoints among the specified IDs. The built-in repository
|
|
48
|
+
* requires active endpoints subscribed to the exact event type or literal '*'.
|
|
49
|
+
* When a tenant ID is provided, endpoint ownership must match that tenant.
|
|
50
|
+
* Pass publish options as the third argument when no tenant ID is needed.
|
|
51
|
+
*/
|
|
39
52
|
async sendToEndpoints(endpointIds, event, tenantIdOrOptions, options) {
|
|
40
53
|
const tenantId = typeof tenantIdOrOptions === 'string' ? tenantIdOrOptions : undefined;
|
|
41
54
|
const publishOptions = typeof tenantIdOrOptions === 'object' ? tenantIdOrOptions : options;
|
|
@@ -80,7 +93,7 @@ let WebhookService = WebhookService_1 = class WebhookService {
|
|
|
80
93
|
}
|
|
81
94
|
async saveEventInTransaction(tx, eventType, payload, tenantId, options) {
|
|
82
95
|
if (!options?.idempotencyKey) {
|
|
83
|
-
const id = await this.eventRepo.saveEventInTransaction(tx, eventType, payload, tenantId);
|
|
96
|
+
const id = await this.eventRepo.saveEventInTransaction(tx, eventType, payload, tenantId, options);
|
|
84
97
|
return { id, created: true };
|
|
85
98
|
}
|
|
86
99
|
if (!this.eventRepo.saveEventOnceInTransaction) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"webhook.service.js","sourceRoot":"","sources":["../src/webhook.service.ts"],"names":[],"mappings":"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
|
|
1
|
+
{"version":3,"file":"webhook.service.js","sourceRoot":"","sources":["../src/webhook.service.ts"],"names":[],"mappings":"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"}
|
package/docs/usage.md
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# Using @nestarc/webhook
|
|
2
|
+
|
|
3
|
+
This is the consumer contract for **0.13.2**. See the [0.13.2 changes](../CHANGELOG.md#0132---2026-09-11); if that version is not yet available on npm, use a locally packed build. In 0.13.1, correlation IDs are only stored with idempotency keys, replay always uses five attempts, and manual retry does not guard against purged payloads.
|
|
4
|
+
|
|
5
|
+
Start with the [README](../README.md) and the [executable example](../examples/quick-start/README.md). Online references: [usage documentation](https://nestarc.dev/packages/webhook/) and [API reference](https://nestarc.dev/api/webhook/). Use the types in your installed package when they differ from an unreleased example.
|
|
6
|
+
|
|
7
|
+
## Runtime and ownership
|
|
8
|
+
|
|
9
|
+
- The default adapters use PostgreSQL and a Prisma client supplied by the application. No Redis, broker, or separate webhook server is required.
|
|
10
|
+
- Supported peers: NestJS 10/11, `@nestjs/schedule` 4/5, Prisma Client 5/6/7. Match peer versions; do not bypass peer validation.
|
|
11
|
+
- The package declares Node >=20. Prisma 7.10.0 requires Node `^20.19 || ^22.12 || >=24.0`; use a supported version for your selected dependencies.
|
|
12
|
+
- Prisma 7 needs a driver adapter and a generated client. For a CommonJS Nest application, configure `moduleFormat = "cjs"` in the `prisma-client` generator. The runnable example includes generation and compilation.
|
|
13
|
+
- The application owns Prisma connection setup and shutdown. For signal handling, enable Nest shutdown hooks or call `app.close()` explicitly; then disconnect the application-owned database client.
|
|
14
|
+
- Apply SQL to the same database/schema used by the client. New installs use `src/sql/create-webhook-tables.sql` from the installed package. Existing pre-0.9 installations run v0.9.0, v0.12.0, then v0.13.0 migrations; skip migrations already applied. v0.12.0 adds performance indexes.
|
|
15
|
+
|
|
16
|
+
## First delivery
|
|
17
|
+
|
|
18
|
+
1. Prepare the database, client, and receiving HTTP endpoint.
|
|
19
|
+
2. Register `WebhookModule.forRoot()` or `forRootAsync()` with that client.
|
|
20
|
+
3. Create an endpoint with the intended tenant and subscriptions; provision its returned secret to the receiver.
|
|
21
|
+
4. Define a `WebhookEvent` subclass with a nonempty static `eventType` and publish it.
|
|
22
|
+
5. Verify the raw request body and headers at the receiver, deduplicate the event, then acknowledge with `2xx`.
|
|
23
|
+
6. Inspect `getDeliveryLogs()` and `getDeliveryAttempts()` for the delivery result.
|
|
24
|
+
|
|
25
|
+
Publishing without any matching endpoints stores the event but creates no deliveries. Creating an endpoint later does not automatically deliver past events. Explicit replay can enqueue a retained event for current matching endpoints.
|
|
26
|
+
|
|
27
|
+
## Publish and tenant contract
|
|
28
|
+
|
|
29
|
+
| API | Destination selection with default repositories | Result |
|
|
30
|
+
| --- | --- | --- |
|
|
31
|
+
| `send(event, options?)` | Every active endpoint subscribed to the event, across all tenants, including endpoints with no tenant | `Promise<string>` event ID |
|
|
32
|
+
| `sendToTenant(tenantId, event, options?)` | Active subscribed endpoints whose tenant equals `tenantId`; global endpoints are excluded | `Promise<string>` event ID |
|
|
33
|
+
| `sendToEndpoints(ids, event, options?)` | Requested IDs, still filtered by active status and subscription, across tenants | `Promise<string>` event ID |
|
|
34
|
+
| `sendToEndpoints(ids, event, tenantId, options?)` | Requested IDs, also filtered to the specified tenant | `Promise<string>` event ID |
|
|
35
|
+
|
|
36
|
+
Subscriptions match an exact event name or the literal `"*"`. Patterns such as `"order.*"` are not supported. Endpoint IDs and event IDs are UUIDs with the PostgreSQL adapters. Tenant IDs are opaque strings.
|
|
37
|
+
|
|
38
|
+
`await send(...)` means the event and initial delivery rows have committed, not that HTTP delivery succeeded. The package transaction does not include an application operation such as `saveOrder()`. If both must commit atomically, integrate an application outbox/transaction strategy; do not assume two awaited calls provide that guarantee.
|
|
39
|
+
|
|
40
|
+
All publish methods accept `WebhookPublishOptions`:
|
|
41
|
+
|
|
42
|
+
- `idempotencyKey`: deduplicates by tenant, event type, and key. A duplicate returns the original event ID and creates no new delivery rows, even if the payload or targeted endpoint list changed. Reuse a key only for the same business event. This is producer-side deduplication, not receiver-side exactly-once processing.
|
|
43
|
+
- `correlationId`: diagnostic event metadata. In this source version it is stored independently of idempotency. It is not added to HTTP headers or the delivered payload. It does not deduplicate events. Duplicate idempotent publishes retain the original metadata.
|
|
44
|
+
|
|
45
|
+
The delivered JSON is `{ "type": "order.created", "data": { ...eventFields } }`. `sanitizePayload` receives the data object before persistence; its output is also what receivers receive.
|
|
46
|
+
|
|
47
|
+
## Delivery, retry, and concurrency
|
|
48
|
+
|
|
49
|
+
Workers atomically claim database rows using `FOR UPDATE SKIP LOCKED`. Retries, lost acknowledgements, crashes, and database write failures can produce duplicate HTTP requests. Delivery uses a bounded attempt budget; it does not guarantee eventual success or exactly-once delivery. No ordering guarantee is provided between events.
|
|
50
|
+
|
|
51
|
+
`delivery.maxRetries` means **total attempts including the first**. The default five attempts allow four retries: 30 seconds, 5 minutes, 30 minutes, and 2 hours. A sixth attempt uses the 24-hour delay; later retries reuse 24 hours. Jitter adds 0–10% to a delay, never subtracts from it. These are minimum scheduling delays; polling, backlog, and outages can delay actual dispatch.
|
|
52
|
+
|
|
53
|
+
| Result | Action |
|
|
54
|
+
| --- | --- |
|
|
55
|
+
| `2xx` | Mark `SENT` |
|
|
56
|
+
| `3xx`, `408`, `409`, `425`, `429`, `5xx`, network/validation/dispatch failure | Retry while attempts remain; otherwise mark `FAILED` |
|
|
57
|
+
| Other `4xx` | Mark `FAILED` immediately |
|
|
58
|
+
|
|
59
|
+
The fixed schedule does not interpret `Retry-After`. Redirects are not followed. The default class is named `FetchHttpClient` for compatibility but uses Node `http`/`https.request`, connecting to prevalidated IPs while preserving the original hostname for HTTP/TLS. Custom clients must honor the supplied `resolvedIpAddresses` to retain that DNS-rebinding protection.
|
|
60
|
+
|
|
61
|
+
The circuit breaker disables an endpoint for **new normal deliveries** after consecutive failures. Already queued deliveries and retries continue. A successful queued delivery may reset the breaker before cooldown; otherwise cooldown recovery can reactivate breaker-disabled endpoints. Manually disabled endpoints are not automatically reactivated by breaker recovery. Notification and observer callbacks are best-effort, not a durable notification queue.
|
|
62
|
+
|
|
63
|
+
## Signature verification and rotation
|
|
64
|
+
|
|
65
|
+
The HMAC-SHA256 input and `webhook-id`, `webhook-timestamp`, `webhook-signature` headers follow the Standard Webhooks signing format. This package accepts bare base64 secrets with at least 16 decoded bytes for compatibility and generates 32-byte secrets. It does not accept `whsec_`-prefixed secrets through endpoint management; remove the prefix only when converting a known compatible secret. Do not describe all secret formats or every Standard Webhooks recommendation as supported.
|
|
66
|
+
|
|
67
|
+
Verify the **original UTF-8 body**, not JSON that has been parsed and serialized again. Prefer `verifyWithTolerance(..., { toleranceSeconds: 300 })`; `verify()` alone performs no freshness check. Freshness permits repeated requests within the tolerance window. Store a receiver-scoped `webhook-id` deduplication key atomically with your business effect. Use durable shared storage across receiver instances. The example's in-memory set is only a single-process demonstration.
|
|
68
|
+
|
|
69
|
+
`createEndpoint()` and `rotateSecret()` return the new provisioning secret. Read APIs omit it. The built-in vault stores secrets as plaintext unless you supply an encryption vault.
|
|
70
|
+
|
|
71
|
+
New delivery rows snapshot their URL and signing secret **when queued**. Legacy rows created before snapshot support, with null snapshot columns, fall back to the endpoint's live URL/current key. Rotation expiry controls whether a previous secret is copied as a secondary signature into a new delivery at that moment:
|
|
72
|
+
|
|
73
|
+
| When the delivery was queued | Keys retained for dispatch and retries |
|
|
74
|
+
| --- | --- |
|
|
75
|
+
| Before rotation | Its original key |
|
|
76
|
+
| During the overlap window | Current and previous keys |
|
|
77
|
+
| After the overlap window | Current key only |
|
|
78
|
+
|
|
79
|
+
Dispatch does not reevaluate expiry for existing snapshots. Rotation neither rewrites the queue nor immediately revokes a compromised key for queued deliveries. Receiver acceptance windows and operational handling of existing deliveries must account for this behavior. Replay creates fresh snapshots; manual retry preserves existing snapshots.
|
|
80
|
+
|
|
81
|
+
## Administration and retention
|
|
82
|
+
|
|
83
|
+
| Operation | Contract |
|
|
84
|
+
| --- | --- |
|
|
85
|
+
| `createEndpoint(dto)` | Returns endpoint and secret; invalid URL or secret throws |
|
|
86
|
+
| `getEndpoint(id)` / `updateEndpoint(id, dto)` | Return endpoint without secret, or `null` when missing |
|
|
87
|
+
| `rotateSecret(id, dto)` | Requires a future `Date`; returns endpoint and new secret, or `null` |
|
|
88
|
+
| `deleteEndpoint(id)` | Returns `false` when missing. The default schema prevents deletion while delivery rows reference the endpoint, raising a foreign-key error; disable it instead when retaining history |
|
|
89
|
+
| `sendTestEvent(id)` | Returns event ID, or `null` when missing; explicit diagnostic delivery bypasses normal subscription matching |
|
|
90
|
+
| `retryDelivery(id, options?)` | Requeues a failed delivery with retained payload and grants at least one additional attempt; returns `false` if ineligible/missing |
|
|
91
|
+
| `retryFailedDeliveries(filters, options?)` | Returns `{ matched, retried, skipped }`; limit defaults to 100, allowed range 1–1000 |
|
|
92
|
+
| `replayEvent(id, options?)` | Returns `{ eventId, deliveriesCreated, endpointIds }`; creates new rows for currently active matching endpoints using the configured total attempt limit |
|
|
93
|
+
| `purgeExpiredData(now?)` | Returns payload/response purge counts; must be invoked by the application's scheduler |
|
|
94
|
+
|
|
95
|
+
Replay reuses the original event ID. Receivers deduplicating that ID may acknowledge replay without processing it again. Coordinate intentional reprocessing with the receiver. `options.tenantId` must match the **source event's tenant**, not just the intended destination: a global event cannot be replayed with a different tenant option. `endpointIds` further restricts the destination list. Missing or payload-purged source events throw. Zero matching destinations returns zero created deliveries.
|
|
96
|
+
|
|
97
|
+
The optional retry/replay `reason` field is metadata for custom adapters. The built-in Prisma adapter does not persist it. Record support actions in your application's audit log if needed.
|
|
98
|
+
|
|
99
|
+
Retention is disabled by default. Payload purging replaces data with `{}` and marks it purged; it does not delete the event/idempotency key. This source version rejects retry and replay of purged payloads. Response retention clears response bodies only; delivery/attempt metadata remains. `sanitizeResponseBody` can suppress responses before storage.
|
|
100
|
+
|
|
101
|
+
Delivery logs show the latest persisted state of a delivery. Attempt records show recorded attempts separately. The default transport retains at most 4096 UTF-16 code units of a response body; logs are not a complete copy of every response. An interrupted process can lose the response before recording it; stale recovery records the recovery, not the lost HTTP response. The `responseBodyTruncated` flag reflects truncation by the repository and cannot detect bytes already discarded by a custom or built-in HTTP client.
|
|
102
|
+
|
|
103
|
+
## Custom adapters and authoritative references
|
|
104
|
+
|
|
105
|
+
Import public services, options, records, tokens, and ports from `@nestarc/webhook`. `WebhookDeliveryWorker`, dispatcher, retry policy, and circuit breaker exports are advanced/internal integration surfaces; normal applications use `WebhookModule` and public services.
|
|
106
|
+
|
|
107
|
+
Custom repositories must preserve transaction and tenant boundaries. Optional port capabilities include idempotent persistence, bulk retry, replay, retention, and backlog summary; calling unsupported operations throws or is unavailable. Event repositories should store the optional correlation metadata. Replay repositories receive the configured attempt budget as an optional third argument. A method with fewer parameters remains TypeScript-compatible, but must be updated to use the new metadata/budget.
|
|
108
|
+
|
|
109
|
+
For this checkout, prefer current public types and this guide over dated files in `docs/`. Historical plans explain past decisions and are not installation recipes. The [changelog](../CHANGELOG.md) identifies released versus pending behavior.
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# Runnable NestJS webhook example
|
|
2
|
+
|
|
3
|
+
This example starts a NestJS application context and a receiver on an ephemeral
|
|
4
|
+
loopback port, registers an endpoint, publishes a tenant event, and lets the normal
|
|
5
|
+
scheduled worker deliver it. It verifies the raw request signature, rejects a
|
|
6
|
+
tampered body, acknowledges a duplicate without processing it again, and checks
|
|
7
|
+
the persisted `SENT` delivery and its attempt log.
|
|
8
|
+
|
|
9
|
+
## Run
|
|
10
|
+
|
|
11
|
+
This example targets `@nestarc/webhook@0.13.2`. Before that version is available
|
|
12
|
+
on npm, use the [packed-source verification](#verify-the-repository-build) below.
|
|
13
|
+
|
|
14
|
+
Use Node.js **20.19+, 22.12+, or 24+** (the supported Prisma 7 release lines) and
|
|
15
|
+
PostgreSQL **14+**. The database user needs permission to create schemas. Use a
|
|
16
|
+
local test database; each run creates a random schema and drops only that schema
|
|
17
|
+
when it finishes. Existing webhook tables are not used.
|
|
18
|
+
|
|
19
|
+
From this directory:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
export DATABASE_URL='postgresql://webhook_test:webhook_test@localhost:5433/webhook_test'
|
|
23
|
+
npm install --strict-peer-deps
|
|
24
|
+
npm start
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
For example, start a disposable PostgreSQL database with Docker first:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
docker run --rm --name webhook-quick-start-db \
|
|
31
|
+
-e POSTGRES_USER=webhook_test \
|
|
32
|
+
-e POSTGRES_PASSWORD=webhook_test \
|
|
33
|
+
-e POSTGRES_DB=webhook_test \
|
|
34
|
+
-p 127.0.0.1:5433:5432 postgres:16-alpine
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Wait for PostgreSQL to report that it is ready to accept connections, then run the
|
|
38
|
+
example in a second terminal. Stop the Docker command when finished.
|
|
39
|
+
|
|
40
|
+
Expected output:
|
|
41
|
+
|
|
42
|
+
```text
|
|
43
|
+
Verified webhook: type=order.created tenant=tenant_example delivery=SENT attempts=1
|
|
44
|
+
Verified receiver: tampered=401 duplicate=200 processed=1
|
|
45
|
+
Quick start passed; temporary schema removed and connections closed.
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
`npm start` generates the Prisma 7 client, compiles the CommonJS application, and
|
|
49
|
+
runs it. The package uses raw SQL, so the Prisma schema needs no webhook models.
|
|
50
|
+
The example applies the SQL shipped with the installed package to its temporary
|
|
51
|
+
schema; it skips the `pgcrypto` extension because PostgreSQL 14+ already supplies
|
|
52
|
+
`gen_random_uuid()`.
|
|
53
|
+
|
|
54
|
+
## Use the pattern in your application
|
|
55
|
+
|
|
56
|
+
- Register an endpoint and provide its secret to the receiver **before** publishing.
|
|
57
|
+
`sendToTenant()` limits matching destinations to that tenant. `send()` broadcasts
|
|
58
|
+
to matching endpoints across tenants.
|
|
59
|
+
- Verify the untouched request body before parsing JSON. A successful
|
|
60
|
+
`verifyWithTolerance()` check accepts repeated valid requests within the window;
|
|
61
|
+
deduplicate by `webhook-id` separately.
|
|
62
|
+
- The in-memory `Set` here lasts for one process. In production, store the event ID
|
|
63
|
+
durably with a unique constraint and commit it atomically with the business
|
|
64
|
+
change. Concurrent requests, restarts, and retries must not repeat that change.
|
|
65
|
+
- `allowPrivateUrls: true` permits this loopback destination. Omit it for production
|
|
66
|
+
public destinations, use HTTPS, and store signing secrets securely.
|
|
67
|
+
- Replace the temporary-schema setup with your normal versioned migrations, and
|
|
68
|
+
keep your application's existing Prisma client and Nest lifecycle. This finite
|
|
69
|
+
example closes Nest before disconnecting the database.
|
|
70
|
+
|
|
71
|
+
## Verify the repository build
|
|
72
|
+
|
|
73
|
+
From the repository root, with `DATABASE_URL` set, run:
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
npm run test:consumer:quick-start
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
The verifier packs the current build, copies this example into a temporary
|
|
80
|
+
consumer directory, installs the tarball with strict peer dependency resolution,
|
|
81
|
+
and runs the same `npm start` command. Application imports use only the package's
|
|
82
|
+
public root exports; SQL comes from the installed package. No repository source
|
|
83
|
+
imports or mocked database/HTTP adapters are used.
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import 'reflect-metadata';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { readFileSync } from 'node:fs';
|
|
5
|
+
import { createServer, IncomingHttpHeaders, ServerResponse } from 'node:http';
|
|
6
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
7
|
+
import { INestApplicationContext, Module } from '@nestjs/common';
|
|
8
|
+
import { NestFactory } from '@nestjs/core';
|
|
9
|
+
import { PrismaPg } from '@prisma/adapter-pg';
|
|
10
|
+
import {
|
|
11
|
+
WebhookDeliveryAdminService,
|
|
12
|
+
WebhookEndpointAdminService,
|
|
13
|
+
WebhookEvent,
|
|
14
|
+
WebhookModule,
|
|
15
|
+
WebhookService,
|
|
16
|
+
WebhookSigner,
|
|
17
|
+
} from '@nestarc/webhook';
|
|
18
|
+
import { PrismaClient } from './generated/prisma/client';
|
|
19
|
+
|
|
20
|
+
class OrderCreatedEvent extends WebhookEvent {
|
|
21
|
+
static readonly eventType = 'order.created';
|
|
22
|
+
|
|
23
|
+
constructor(public readonly orderId: string) {
|
|
24
|
+
super();
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function main(): Promise<void> {
|
|
29
|
+
const connectionString = process.env.DATABASE_URL;
|
|
30
|
+
if (!connectionString) throw new Error('Set DATABASE_URL to a local test PostgreSQL database');
|
|
31
|
+
|
|
32
|
+
// Each run owns one temporary schema; no existing webhook tables are changed.
|
|
33
|
+
const schema = `webhook_example_${randomUUID().replaceAll('-', '')}`;
|
|
34
|
+
const scopedUrl = new URL(connectionString);
|
|
35
|
+
scopedUrl.searchParams.set('options', `-c search_path=${schema}`);
|
|
36
|
+
const database = new PrismaClient({
|
|
37
|
+
adapter: new PrismaPg({ connectionString, connectionTimeoutMillis: 5000 }),
|
|
38
|
+
});
|
|
39
|
+
const prisma = new PrismaClient({
|
|
40
|
+
adapter: new PrismaPg({
|
|
41
|
+
connectionString: scopedUrl.toString(),
|
|
42
|
+
connectionTimeoutMillis: 5000,
|
|
43
|
+
}),
|
|
44
|
+
});
|
|
45
|
+
const signer = new WebhookSigner();
|
|
46
|
+
// This Set demonstrates the check for a single process. Production receivers
|
|
47
|
+
// must persist a unique event ID with their business change in one transaction.
|
|
48
|
+
const processed = new Set<string>();
|
|
49
|
+
const received: Array<{ headers: IncomingHttpHeaders; body: string }> = [];
|
|
50
|
+
let secret = '';
|
|
51
|
+
let app: INestApplicationContext | undefined;
|
|
52
|
+
let schemaCreated = false;
|
|
53
|
+
|
|
54
|
+
function respond(response: ServerResponse, status: number, body: string): void {
|
|
55
|
+
response.writeHead(status, { 'content-type': 'text/plain' });
|
|
56
|
+
response.end(body);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const receiver = createServer((request, response) => {
|
|
60
|
+
if (request.method !== 'POST' || request.url !== '/webhooks') {
|
|
61
|
+
respond(response, 404, 'Not found');
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const chunks: Buffer[] = [];
|
|
65
|
+
let bytes = 0;
|
|
66
|
+
request.on('data', (chunk: Buffer) => {
|
|
67
|
+
bytes += chunk.length;
|
|
68
|
+
if (bytes <= 1024 * 1024) chunks.push(chunk);
|
|
69
|
+
});
|
|
70
|
+
request.on('error', () => {
|
|
71
|
+
if (!response.headersSent) respond(response, 400, 'Invalid request');
|
|
72
|
+
});
|
|
73
|
+
request.on('end', () => {
|
|
74
|
+
if (bytes > 1024 * 1024) return respond(response, 413, 'Body too large');
|
|
75
|
+
// Verify the exact request body before JSON parsing or any business changes.
|
|
76
|
+
const body = Buffer.concat(chunks).toString('utf8');
|
|
77
|
+
const eventId = request.headers['webhook-id'];
|
|
78
|
+
const timestamp = request.headers['webhook-timestamp'];
|
|
79
|
+
const signature = request.headers['webhook-signature'];
|
|
80
|
+
if (
|
|
81
|
+
!secret ||
|
|
82
|
+
typeof eventId !== 'string' ||
|
|
83
|
+
!eventId ||
|
|
84
|
+
typeof timestamp !== 'string' ||
|
|
85
|
+
!/^\d+$/.test(timestamp) ||
|
|
86
|
+
typeof signature !== 'string' ||
|
|
87
|
+
!signer.verifyWithTolerance(eventId, Number(timestamp), body, secret, signature, {
|
|
88
|
+
toleranceSeconds: 300,
|
|
89
|
+
})
|
|
90
|
+
) {
|
|
91
|
+
return respond(response, 401, 'Invalid or expired signature');
|
|
92
|
+
}
|
|
93
|
+
if (processed.has(eventId)) return respond(response, 200, 'Already processed');
|
|
94
|
+
|
|
95
|
+
let event: { type?: string; data?: { orderId?: string } };
|
|
96
|
+
try {
|
|
97
|
+
event = JSON.parse(body);
|
|
98
|
+
} catch {
|
|
99
|
+
return respond(response, 400, 'Invalid JSON');
|
|
100
|
+
}
|
|
101
|
+
if (event?.type !== 'order.created' || event.data?.orderId !== 'order_123') {
|
|
102
|
+
return respond(response, 400, 'Unexpected event');
|
|
103
|
+
}
|
|
104
|
+
// The example's business action is recording this verified request.
|
|
105
|
+
received.push({ headers: request.headers, body });
|
|
106
|
+
processed.add(eventId);
|
|
107
|
+
respond(response, 200, 'Processed');
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
await database.$connect();
|
|
113
|
+
// schema is generated locally from UUID characters, never from user input.
|
|
114
|
+
await database.$executeRawUnsafe(`CREATE SCHEMA "${schema}"`);
|
|
115
|
+
schemaCreated = true;
|
|
116
|
+
await prisma.$connect();
|
|
117
|
+
const sql = readFileSync(
|
|
118
|
+
require.resolve('@nestarc/webhook/src/sql/create-webhook-tables.sql'),
|
|
119
|
+
'utf8',
|
|
120
|
+
);
|
|
121
|
+
const statements = sql
|
|
122
|
+
.split('\n')
|
|
123
|
+
.filter((line) => !line.trimStart().startsWith('--'))
|
|
124
|
+
.join('\n')
|
|
125
|
+
.split(';')
|
|
126
|
+
.map((statement) => statement.trim())
|
|
127
|
+
.filter(Boolean);
|
|
128
|
+
for (const statement of statements) {
|
|
129
|
+
// PostgreSQL 14+ supplies gen_random_uuid(); avoid creating a global extension.
|
|
130
|
+
if (/^CREATE EXTENSION\b/i.test(statement)) continue;
|
|
131
|
+
await prisma.$executeRawUnsafe(statement);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
await new Promise<void>((resolve, reject) => {
|
|
135
|
+
receiver.once('error', reject);
|
|
136
|
+
receiver.listen(0, '127.0.0.1', () => {
|
|
137
|
+
receiver.removeListener('error', reject);
|
|
138
|
+
resolve();
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
const address = receiver.address();
|
|
142
|
+
assert(address && typeof address !== 'string');
|
|
143
|
+
const url = `http://127.0.0.1:${address.port}/webhooks`;
|
|
144
|
+
|
|
145
|
+
@Module({
|
|
146
|
+
imports: [
|
|
147
|
+
WebhookModule.forRoot({
|
|
148
|
+
prisma,
|
|
149
|
+
polling: { interval: 100 },
|
|
150
|
+
delivery: { timeout: 5000, maxRetries: 1 },
|
|
151
|
+
allowPrivateUrls: true, // Only for this loopback example; omit in production.
|
|
152
|
+
}),
|
|
153
|
+
],
|
|
154
|
+
})
|
|
155
|
+
class AppModule {}
|
|
156
|
+
|
|
157
|
+
app = await NestFactory.createApplicationContext(AppModule, {
|
|
158
|
+
logger: ['error', 'warn'],
|
|
159
|
+
abortOnError: false,
|
|
160
|
+
});
|
|
161
|
+
const endpoints = app.get(WebhookEndpointAdminService);
|
|
162
|
+
const deliveries = app.get(WebhookDeliveryAdminService);
|
|
163
|
+
const webhooks = app.get(WebhookService);
|
|
164
|
+
|
|
165
|
+
// Register the destination and securely provision its secret before publishing.
|
|
166
|
+
const endpoint = await endpoints.createEndpoint({
|
|
167
|
+
url,
|
|
168
|
+
events: ['order.created'],
|
|
169
|
+
tenantId: 'tenant_example',
|
|
170
|
+
});
|
|
171
|
+
secret = endpoint.secret;
|
|
172
|
+
const eventId = await webhooks.sendToTenant(
|
|
173
|
+
'tenant_example',
|
|
174
|
+
new OrderCreatedEvent('order_123'),
|
|
175
|
+
{ idempotencyKey: 'order_123:created', correlationId: 'example_request' },
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
let logs = await deliveries.getDeliveryLogs(endpoint.id);
|
|
179
|
+
const deadline = Date.now() + 15_000;
|
|
180
|
+
while (logs[0]?.status !== 'SENT') {
|
|
181
|
+
assert.notEqual(logs[0]?.status, 'FAILED', 'Delivery failed');
|
|
182
|
+
assert(Date.now() < deadline, 'Timed out waiting for scheduled delivery');
|
|
183
|
+
await delay(50);
|
|
184
|
+
logs = await deliveries.getDeliveryLogs(endpoint.id);
|
|
185
|
+
}
|
|
186
|
+
assert.equal(logs.length, 1);
|
|
187
|
+
assert.equal(logs[0].eventId, eventId);
|
|
188
|
+
assert.equal(logs[0].tenantId, 'tenant_example');
|
|
189
|
+
assert.equal(logs[0].attempts, 1);
|
|
190
|
+
const attempts = await deliveries.getDeliveryAttempts(logs[0].id);
|
|
191
|
+
assert.equal(attempts.length, 1);
|
|
192
|
+
assert.equal(attempts[0].status, 'SENT');
|
|
193
|
+
assert.equal(attempts[0].responseStatus, 200);
|
|
194
|
+
assert.equal(received.length, 1);
|
|
195
|
+
assert.equal(received[0].headers['webhook-id'], eventId);
|
|
196
|
+
|
|
197
|
+
// A timestamp check alone permits a duplicate within its tolerance window.
|
|
198
|
+
const original = received[0];
|
|
199
|
+
const headers = {
|
|
200
|
+
'content-type': 'application/json',
|
|
201
|
+
'webhook-id': eventId,
|
|
202
|
+
'webhook-timestamp': String(original.headers['webhook-timestamp']),
|
|
203
|
+
'webhook-signature': String(original.headers['webhook-signature']),
|
|
204
|
+
};
|
|
205
|
+
const tampered = await fetch(url, {
|
|
206
|
+
method: 'POST', headers, body: `${original.body} `, signal: AbortSignal.timeout(5000),
|
|
207
|
+
});
|
|
208
|
+
assert.equal(tampered.status, 401);
|
|
209
|
+
await tampered.text();
|
|
210
|
+
const duplicate = await fetch(url, {
|
|
211
|
+
method: 'POST', headers, body: original.body, signal: AbortSignal.timeout(5000),
|
|
212
|
+
});
|
|
213
|
+
assert.equal(duplicate.status, 200);
|
|
214
|
+
assert.equal(await duplicate.text(), 'Already processed');
|
|
215
|
+
assert.equal(received.length, 1);
|
|
216
|
+
assert.equal(processed.size, 1);
|
|
217
|
+
|
|
218
|
+
console.log('Verified webhook: type=order.created tenant=tenant_example delivery=SENT attempts=1');
|
|
219
|
+
console.log('Verified receiver: tampered=401 duplicate=200 processed=1');
|
|
220
|
+
} finally {
|
|
221
|
+
// Stop polling and await in-flight work before closing its resources.
|
|
222
|
+
try {
|
|
223
|
+
await app?.close();
|
|
224
|
+
} finally {
|
|
225
|
+
try {
|
|
226
|
+
if (receiver.listening) {
|
|
227
|
+
await new Promise<void>((resolve, reject) =>
|
|
228
|
+
receiver.close((error) => error ? reject(error) : resolve()),
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
} finally {
|
|
232
|
+
try {
|
|
233
|
+
await prisma.$disconnect();
|
|
234
|
+
if (schemaCreated) await database.$executeRawUnsafe(`DROP SCHEMA "${schema}" CASCADE`);
|
|
235
|
+
} finally {
|
|
236
|
+
await database.$disconnect();
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
console.log('Quick start passed; temporary schema removed and connections closed.');
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
main().catch((error: unknown) => {
|
|
245
|
+
console.error(error);
|
|
246
|
+
process.exitCode = 1;
|
|
247
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "webhook-quick-start",
|
|
3
|
+
"private": true,
|
|
4
|
+
"version": "0.0.0",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"generate": "prisma generate",
|
|
7
|
+
"build": "tsc -p tsconfig.json",
|
|
8
|
+
"start": "npm run generate && npm run build && node dist/main.js"
|
|
9
|
+
},
|
|
10
|
+
"engines": {
|
|
11
|
+
"node": "^20.19 || ^22.12 || >=24.0"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"@nestarc/webhook": "0.13.2",
|
|
15
|
+
"@nestjs/common": "11.2.1",
|
|
16
|
+
"@nestjs/core": "11.2.1",
|
|
17
|
+
"@nestjs/schedule": "5.0.1",
|
|
18
|
+
"@prisma/adapter-pg": "7.10.0",
|
|
19
|
+
"@prisma/client": "7.10.0",
|
|
20
|
+
"pg": "8.23.0",
|
|
21
|
+
"reflect-metadata": "0.2.2",
|
|
22
|
+
"rxjs": "7.8.2"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"@types/node": "25.6.0",
|
|
26
|
+
"prisma": "7.10.0",
|
|
27
|
+
"typescript": "5.9.3"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "commonjs",
|
|
5
|
+
"moduleResolution": "node",
|
|
6
|
+
"esModuleInterop": true,
|
|
7
|
+
"experimentalDecorators": true,
|
|
8
|
+
"emitDecoratorMetadata": true,
|
|
9
|
+
"strict": true,
|
|
10
|
+
"skipLibCheck": false,
|
|
11
|
+
"outDir": "dist",
|
|
12
|
+
"rootDir": "."
|
|
13
|
+
},
|
|
14
|
+
"include": ["main.ts", "generated/**/*.ts"]
|
|
15
|
+
}
|