@antasphere/chassis-server 1.0.2 → 1.2.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/dist/api/apikeys.d.ts +4 -1
- package/dist/api/apikeys.d.ts.map +1 -1
- package/dist/api/apikeys.js +11 -1
- package/dist/api/apikeys.js.map +1 -1
- package/dist/api/cli-package.d.ts +58 -0
- package/dist/api/cli-package.d.ts.map +1 -0
- package/dist/api/cli-package.js +83 -0
- package/dist/api/cli-package.js.map +1 -0
- package/dist/api/create-api.d.ts +3 -0
- package/dist/api/create-api.d.ts.map +1 -1
- package/dist/api/create-api.js +33 -4
- package/dist/api/create-api.js.map +1 -1
- package/dist/api/index.d.ts +1 -0
- package/dist/api/index.d.ts.map +1 -1
- package/dist/api/index.js +1 -0
- package/dist/api/index.js.map +1 -1
- package/dist/api/invitations.d.ts.map +1 -1
- package/dist/api/invitations.js +6 -0
- package/dist/api/invitations.js.map +1 -1
- package/dist/api/members.d.ts +5 -2
- package/dist/api/members.d.ts.map +1 -1
- package/dist/api/members.js +1 -0
- package/dist/api/members.js.map +1 -1
- package/dist/api/projects.d.ts +3 -0
- package/dist/api/projects.d.ts.map +1 -1
- package/dist/api/projects.js +13 -1
- package/dist/api/projects.js.map +1 -1
- package/dist/api/webhooks.d.ts +24 -0
- package/dist/api/webhooks.d.ts.map +1 -0
- package/dist/api/webhooks.js +170 -0
- package/dist/api/webhooks.js.map +1 -0
- package/dist/boot.d.ts.map +1 -1
- package/dist/boot.js +47 -4
- package/dist/boot.js.map +1 -1
- package/dist/cli-package/index.d.ts +44 -0
- package/dist/cli-package/index.d.ts.map +1 -0
- package/dist/cli-package/index.js +60 -0
- package/dist/cli-package/index.js.map +1 -0
- package/dist/email/index.d.ts +1 -0
- package/dist/email/index.d.ts.map +1 -1
- package/dist/email/index.js +1 -0
- package/dist/email/index.js.map +1 -1
- package/dist/env.d.ts +11 -0
- package/dist/env.d.ts.map +1 -1
- package/dist/env.js +24 -0
- package/dist/env.js.map +1 -1
- package/dist/identity/demo-pass-rules.d.ts.map +1 -1
- package/dist/identity/demo-pass-rules.js +6 -1
- package/dist/identity/demo-pass-rules.js.map +1 -1
- package/dist/identity/hub-reconcile.d.ts +2 -2
- package/dist/identity/hub-reconcile.d.ts.map +1 -1
- package/dist/identity/hub-reconcile.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/jobs/index.d.ts +1 -1
- package/dist/jobs/index.d.ts.map +1 -1
- package/dist/jobs/index.js.map +1 -1
- package/dist/jobs/pgboss.d.ts +34 -0
- package/dist/jobs/pgboss.d.ts.map +1 -1
- package/dist/jobs/pgboss.js +32 -9
- package/dist/jobs/pgboss.js.map +1 -1
- package/dist/mcp/index.d.ts +1 -0
- package/dist/mcp/index.d.ts.map +1 -1
- package/dist/mcp/index.js +1 -0
- package/dist/mcp/index.js.map +1 -1
- package/dist/mcp/server.d.ts +1 -1
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/server.js +11 -2
- package/dist/mcp/server.js.map +1 -1
- package/dist/mcp/tool-kit.d.ts +2 -0
- package/dist/mcp/tool-kit.d.ts.map +1 -1
- package/dist/mcp/tool-kit.js +5 -1
- package/dist/mcp/tool-kit.js.map +1 -1
- package/dist/mcp/webhooks.d.ts +16 -0
- package/dist/mcp/webhooks.d.ts.map +1 -0
- package/dist/mcp/webhooks.js +226 -0
- package/dist/mcp/webhooks.js.map +1 -0
- package/dist/members/removal.d.ts +10 -2
- package/dist/members/removal.d.ts.map +1 -1
- package/dist/members/removal.js +3 -0
- package/dist/members/removal.js.map +1 -1
- package/dist/middleware/idempotency.d.ts.map +1 -1
- package/dist/middleware/idempotency.js +3 -1
- package/dist/middleware/idempotency.js.map +1 -1
- package/dist/middleware/scopes.d.ts +9 -1
- package/dist/middleware/scopes.d.ts.map +1 -1
- package/dist/middleware/scopes.js +8 -1
- package/dist/middleware/scopes.js.map +1 -1
- package/dist/secret-box.d.ts +10 -0
- package/dist/secret-box.d.ts.map +1 -0
- package/dist/secret-box.js +87 -0
- package/dist/secret-box.js.map +1 -0
- package/dist/testing/helpers.d.ts +8 -0
- package/dist/testing/helpers.d.ts.map +1 -1
- package/dist/testing/helpers.js.map +1 -1
- package/dist/tool-definition.d.ts +24 -0
- package/dist/tool-definition.d.ts.map +1 -1
- package/dist/tool-definition.js.map +1 -1
- package/dist/webhooks/catalog.d.ts +80 -0
- package/dist/webhooks/catalog.d.ts.map +1 -0
- package/dist/webhooks/catalog.js +134 -0
- package/dist/webhooks/catalog.js.map +1 -0
- package/dist/webhooks/index.d.ts +9 -0
- package/dist/webhooks/index.d.ts.map +1 -0
- package/dist/webhooks/index.js +9 -0
- package/dist/webhooks/index.js.map +1 -0
- package/dist/webhooks/jobs.d.ts +12 -0
- package/dist/webhooks/jobs.d.ts.map +1 -0
- package/dist/webhooks/jobs.js +77 -0
- package/dist/webhooks/jobs.js.map +1 -0
- package/dist/webhooks/mail.d.ts +24 -0
- package/dist/webhooks/mail.d.ts.map +1 -0
- package/dist/webhooks/mail.js +21 -0
- package/dist/webhooks/mail.js.map +1 -0
- package/dist/webhooks/match.d.ts +22 -0
- package/dist/webhooks/match.d.ts.map +1 -0
- package/dist/webhooks/match.js +40 -0
- package/dist/webhooks/match.js.map +1 -0
- package/dist/webhooks/safe-fetch.d.ts +94 -0
- package/dist/webhooks/safe-fetch.d.ts.map +1 -0
- package/dist/webhooks/safe-fetch.js +263 -0
- package/dist/webhooks/safe-fetch.js.map +1 -0
- package/dist/webhooks/service.d.ts +245 -0
- package/dist/webhooks/service.d.ts.map +1 -0
- package/dist/webhooks/service.js +861 -0
- package/dist/webhooks/service.js.map +1 -0
- package/dist/webhooks/signing.d.ts +40 -0
- package/dist/webhooks/signing.d.ts.map +1 -0
- package/dist/webhooks/signing.js +108 -0
- package/dist/webhooks/signing.js.map +1 -0
- package/dist/webhooks/tx-send.d.ts +31 -0
- package/dist/webhooks/tx-send.d.ts.map +1 -0
- package/dist/webhooks/tx-send.js +22 -0
- package/dist/webhooks/tx-send.js.map +1 -0
- package/package.json +11 -3
- package/src/api/apikeys.ts +12 -1
- package/src/api/cli-package.ts +151 -0
- package/src/api/create-api.ts +42 -4
- package/src/api/index.ts +1 -0
- package/src/api/invitations.ts +6 -0
- package/src/api/members.ts +6 -2
- package/src/api/projects.ts +16 -1
- package/src/api/webhooks.ts +205 -0
- package/src/boot.ts +48 -4
- package/src/cli-package/index.ts +88 -0
- package/src/email/index.ts +1 -0
- package/src/env.ts +25 -0
- package/src/identity/demo-pass-rules.ts +6 -1
- package/src/identity/hub-reconcile.ts +2 -2
- package/src/index.ts +13 -1
- package/src/jobs/index.ts +1 -0
- package/src/jobs/pgboss.ts +71 -9
- package/src/mcp/index.ts +1 -0
- package/src/mcp/server.ts +10 -2
- package/src/mcp/tool-kit.ts +7 -1
- package/src/mcp/webhooks.ts +370 -0
- package/src/members/removal.ts +21 -1
- package/src/middleware/idempotency.ts +3 -1
- package/src/middleware/scopes.ts +14 -0
- package/src/secret-box.ts +93 -0
- package/src/testing/helpers.ts +8 -0
- package/src/tool-definition.ts +29 -0
- package/src/webhooks/catalog.ts +205 -0
- package/src/webhooks/index.ts +58 -0
- package/src/webhooks/jobs.ts +92 -0
- package/src/webhooks/mail.ts +48 -0
- package/src/webhooks/match.ts +52 -0
- package/src/webhooks/safe-fetch.ts +335 -0
- package/src/webhooks/service.ts +1089 -0
- package/src/webhooks/signing.ts +132 -0
- package/src/webhooks/tx-send.ts +53 -0
- package/test/host/drizzle/0002_webhooks.sql +49 -0
- package/test/host/drizzle/meta/0002_snapshot.json +3952 -0
- package/test/host/drizzle/meta/_journal.json +8 -1
- package/test/host/identity.ts +6 -1
- package/test/host/minimal-host.ts +2 -1
- package/test/host/minimal-tool.ts +32 -4
- package/test/integration/cli-package.test.ts +403 -0
- package/test/integration/mcp-webhooks.test.ts +360 -0
- package/test/integration/webhooks.test.ts +1684 -0
- package/test/unit/cli-package.test.ts +93 -0
- package/test/unit/secret-box.test.ts +124 -0
- package/test/unit/tx-send.test.ts +23 -0
- package/test/unit/webhook-catalog.test.ts +151 -0
- package/test/unit/webhook-env.test.ts +60 -0
- package/test/unit/webhook-jobs.test.ts +92 -0
- package/test/unit/webhook-match.test.ts +81 -0
- package/test/unit/webhook-safe-fetch.test.ts +330 -0
- package/test/unit/webhook-signing.test.ts +169 -0
|
@@ -0,0 +1,1089 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { and, desc, eq, sql } from 'drizzle-orm';
|
|
3
|
+
import type PgBoss from 'pg-boss';
|
|
4
|
+
import { Counter } from 'prom-client';
|
|
5
|
+
import {
|
|
6
|
+
user as userTable,
|
|
7
|
+
webhookDeliveries,
|
|
8
|
+
webhookEndpoints,
|
|
9
|
+
workspaceMembers,
|
|
10
|
+
workspaces,
|
|
11
|
+
type Db,
|
|
12
|
+
type DbConn,
|
|
13
|
+
type WebhookDeliveryRow,
|
|
14
|
+
type WebhookEndpointRow
|
|
15
|
+
} from '@antasphere/chassis-db';
|
|
16
|
+
import type {
|
|
17
|
+
ToolIdentity,
|
|
18
|
+
WebhookDelivery,
|
|
19
|
+
WebhookEndpoint,
|
|
20
|
+
WebhookEndpointCreate,
|
|
21
|
+
WebhookEndpointUpdate
|
|
22
|
+
} from '@antasphere/chassis-contract';
|
|
23
|
+
import type { Logger } from '../logger.js';
|
|
24
|
+
import type { EmailDriver } from '../email/driver.js';
|
|
25
|
+
import type { MailBrand } from '../email/shell.js';
|
|
26
|
+
import type { EventBus } from '../platform/events.js';
|
|
27
|
+
import { createSecretBox, type SecretBox } from '../secret-box.js';
|
|
28
|
+
import { createdAtText, decodeKeysetCursor, encodeKeysetCursor, keysetBeforeValue } from '../pagination.js';
|
|
29
|
+
import { WEBHOOK_PING_EVENT, type WebhookCatalogIndex, type WebhookSubjectValue } from './catalog.js';
|
|
30
|
+
import { endpointWants } from './match.js';
|
|
31
|
+
import { buildWebhookDisabledEmail } from './mail.js';
|
|
32
|
+
import {
|
|
33
|
+
checkWebhookUrl,
|
|
34
|
+
safePost,
|
|
35
|
+
WebhookUrlRefusedError,
|
|
36
|
+
type SafeFetchOptions,
|
|
37
|
+
type SafeFetchPolicy
|
|
38
|
+
} from './safe-fetch.js';
|
|
39
|
+
import { generateWebhookSecret, webhookHeaders } from './signing.js';
|
|
40
|
+
import { sendInTx } from './tx-send.js';
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Outbound webhooks (PRDCT-3482): the chassis's machinery, tool-neutral.
|
|
44
|
+
*
|
|
45
|
+
* EMIT: a domain write calls `emit(tx, …)` INSIDE its own transaction; one
|
|
46
|
+
* pg-boss job (the event's envelope) is written by the same connection, so
|
|
47
|
+
* the event is durable with the write and never exists for a rolled-back
|
|
48
|
+
* one. A per-replica cache of "does any enabled endpoint of this workspace
|
|
49
|
+
* want this type" (`listenerTtlMs`) makes an emit nobody listens to cost
|
|
50
|
+
* one map lookup: a view with no endpoint costs no job.
|
|
51
|
+
*
|
|
52
|
+
* FAN-OUT (`webhook-events`): the event is matched against the workspace's
|
|
53
|
+
* enabled endpoints (type, then filters); one `webhook_deliveries` row and
|
|
54
|
+
* one delivery job per match, in one transaction.
|
|
55
|
+
*
|
|
56
|
+
* DELIVERY (`webhook-deliveries`): the envelope is signed (Standard
|
|
57
|
+
* Webhooks, two signatures during a rotation overlap) and POSTed through the
|
|
58
|
+
* guarded fetch. A 2xx is `delivered`; anything else fails the job, which
|
|
59
|
+
* pg-boss retries on the queue's budget (ten retries from thirty seconds,
|
|
60
|
+
* about eight and a half hours), then moves to `webhook-deliveries-held`:
|
|
61
|
+
* the row is marked `held`, logged, counted, and queued again an hour later
|
|
62
|
+
* with the whole budget once more, round after round, until the receiver
|
|
63
|
+
* takes it or the endpoint is disabled. A streak of
|
|
64
|
+
* `disableAfterFailures` failures spanning `disableAfterMs` switches the
|
|
65
|
+
* endpoint off and mails its creator once.
|
|
66
|
+
*
|
|
67
|
+
* The secret is stored sealed (`secret-box.ts`, info `chassis:webhook-endpoint`)
|
|
68
|
+
* and shown once, at creation and at rotation.
|
|
69
|
+
*/
|
|
70
|
+
|
|
71
|
+
export const WEBHOOK_EVENTS_QUEUE = 'webhook-events';
|
|
72
|
+
export const WEBHOOK_DELIVERIES_QUEUE = 'webhook-deliveries';
|
|
73
|
+
export const WEBHOOK_DELIVERIES_HELD_QUEUE = 'webhook-deliveries-held';
|
|
74
|
+
export const WEBHOOK_PURGE_QUEUE = 'webhook-purge';
|
|
75
|
+
/** The HKDF info of the endpoints' secret box: never the idempotency one. */
|
|
76
|
+
export const WEBHOOK_SECRET_BOX_INFO = 'chassis:webhook-endpoint';
|
|
77
|
+
|
|
78
|
+
export interface WebhookDials {
|
|
79
|
+
/** Delivery retries before a hold (pg-boss `retryLimit`). */
|
|
80
|
+
retryLimit: number;
|
|
81
|
+
/** The first retry's delay in seconds, doubled each time. */
|
|
82
|
+
retryDelaySeconds: number;
|
|
83
|
+
/** How long a held delivery waits before its next round. */
|
|
84
|
+
heldDelaySeconds: number;
|
|
85
|
+
/** The failure streak that switches an endpoint off… */
|
|
86
|
+
disableAfterFailures: number;
|
|
87
|
+
/** …once it has lasted this long. */
|
|
88
|
+
disableAfterMs: number;
|
|
89
|
+
/** How long "anyone listening?" is remembered per workspace and replica. */
|
|
90
|
+
listenerTtlMs: number;
|
|
91
|
+
/** How long the previous secret keeps signing after a rotation. */
|
|
92
|
+
previousSecretOverlapMs: number;
|
|
93
|
+
/** The guarded fetch's timeout. */
|
|
94
|
+
timeoutMs: number;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export const DEFAULT_WEBHOOK_DIALS: WebhookDials = {
|
|
98
|
+
retryLimit: 10,
|
|
99
|
+
retryDelaySeconds: 30,
|
|
100
|
+
heldDelaySeconds: 3600,
|
|
101
|
+
disableAfterFailures: 20,
|
|
102
|
+
disableAfterMs: 2 * 24 * 3600 * 1000,
|
|
103
|
+
listenerTtlMs: 30_000,
|
|
104
|
+
previousSecretOverlapMs: 24 * 3600 * 1000,
|
|
105
|
+
timeoutMs: 10_000
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/** What `emit` takes: the workspace, the type, the subjects by KEY, the data. */
|
|
109
|
+
export interface WebhookEmit {
|
|
110
|
+
workspaceId: string;
|
|
111
|
+
type: string;
|
|
112
|
+
subjects?: Readonly<Record<string, WebhookSubjectValue>>;
|
|
113
|
+
data: Record<string, unknown>;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The job the fan-out queue carries: the event as the envelope will say it (minus `attempt`). */
|
|
117
|
+
export interface WebhookEventJob {
|
|
118
|
+
id: string;
|
|
119
|
+
type: string;
|
|
120
|
+
occurredAt: string;
|
|
121
|
+
workspaceId: string;
|
|
122
|
+
/** By subject KEY (`project`), the envelope writes them under each subject's FIELD. */
|
|
123
|
+
subjects: Record<string, WebhookSubjectValue>;
|
|
124
|
+
data: Record<string, unknown>;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export interface WebhookDeliveryJob {
|
|
128
|
+
deliveryId: string;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The emitter a tool's services receive (`ServiceCore.webhooks`). */
|
|
132
|
+
export interface WebhookEmitter {
|
|
133
|
+
/**
|
|
134
|
+
* Record that something happened, inside the caller's transaction (or on
|
|
135
|
+
* a plain handle, for a write the chassis does not own). Answers the
|
|
136
|
+
* event id, or null when no endpoint of the workspace listens to the type,
|
|
137
|
+
* or the data does not match the declared schema (logged, never thrown:
|
|
138
|
+
* a webhook must never break the write it reports).
|
|
139
|
+
*/
|
|
140
|
+
emit(conn: DbConn, event: WebhookEmit): Promise<string | null>;
|
|
141
|
+
/**
|
|
142
|
+
* Whether any enabled endpoint of the workspace subscribes to the type:
|
|
143
|
+
* the cheap exit a tool takes BEFORE it computes the facts of an event
|
|
144
|
+
* that is frequent and costs something to describe (a view). Cached per
|
|
145
|
+
* replica; `emit` runs the same check itself.
|
|
146
|
+
*/
|
|
147
|
+
wants(workspaceId: string, type: string): Promise<boolean>;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export interface WebhookServiceDeps {
|
|
151
|
+
db: Db;
|
|
152
|
+
logger: Logger;
|
|
153
|
+
authSecret: string;
|
|
154
|
+
identity: ToolIdentity;
|
|
155
|
+
version: string;
|
|
156
|
+
policy: SafeFetchPolicy;
|
|
157
|
+
email: EmailDriver;
|
|
158
|
+
brand: MailBrand;
|
|
159
|
+
publicBaseUrl: string;
|
|
160
|
+
catalog: WebhookCatalogIndex;
|
|
161
|
+
dials?: Partial<WebhookDials> | undefined;
|
|
162
|
+
/** Test seams of the guarded fetch (a lookup, a transport). */
|
|
163
|
+
fetchOptions?: SafeFetchOptions | undefined;
|
|
164
|
+
now?: () => Date;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export class WebhookRefusedError extends Error {
|
|
168
|
+
constructor(
|
|
169
|
+
readonly code: 'url_refused' | 'not_found' | 'unknown_event' | 'unknown_subject',
|
|
170
|
+
message: string
|
|
171
|
+
) {
|
|
172
|
+
super(message);
|
|
173
|
+
this.name = 'WebhookRefusedError';
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Thrown by an attempt that did not run because the endpoint already holds
|
|
179
|
+
* its share of this replica's pollers (`MAX_IN_FLIGHT_PER_ENDPOINT`): the
|
|
180
|
+
* worker puts the job back in the queue a moment later, its retry budget
|
|
181
|
+
* untouched, so one dead receiver can never take every slot (verifier
|
|
182
|
+
* round 2, MJ-R2-1).
|
|
183
|
+
*/
|
|
184
|
+
export class WebhookDeliveryDeferred extends Error {
|
|
185
|
+
constructor(readonly deliveryId: string) {
|
|
186
|
+
super('deferred');
|
|
187
|
+
this.name = 'WebhookDeliveryDeferred';
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** How many attempts one endpoint may have in flight on one replica at once. */
|
|
192
|
+
export const MAX_IN_FLIGHT_PER_ENDPOINT = 2;
|
|
193
|
+
|
|
194
|
+
/** Thrown by a retryable attempt that failed, so pg-boss retries the job. */
|
|
195
|
+
export class WebhookDeliveryFailed extends Error {
|
|
196
|
+
constructor(
|
|
197
|
+
readonly deliveryId: string,
|
|
198
|
+
message: string
|
|
199
|
+
) {
|
|
200
|
+
super(message);
|
|
201
|
+
this.name = 'WebhookDeliveryFailed';
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
type Listeners = { at: number; patterns: string[][] };
|
|
206
|
+
|
|
207
|
+
const MAX_ERROR_TEXT = 300;
|
|
208
|
+
|
|
209
|
+
export class WebhookService implements WebhookEmitter {
|
|
210
|
+
readonly dials: WebhookDials;
|
|
211
|
+
readonly promMetrics: Counter<string>[];
|
|
212
|
+
private readonly box: SecretBox;
|
|
213
|
+
private readonly listeners = new Map<string, Listeners>();
|
|
214
|
+
private readonly deliveries: Counter<string>;
|
|
215
|
+
private readonly held: Counter<string>;
|
|
216
|
+
private readonly disabled: Counter<string>;
|
|
217
|
+
private readonly now: () => Date;
|
|
218
|
+
/** Filled by boot once pg-boss exists (`attach`); the jobs are declared before it. */
|
|
219
|
+
private boss: PgBoss | null = null;
|
|
220
|
+
/** Attempts in flight per endpoint on this replica (the per-endpoint bound). */
|
|
221
|
+
private readonly inFlight = new Map<string, number>();
|
|
222
|
+
|
|
223
|
+
constructor(private readonly deps: WebhookServiceDeps) {
|
|
224
|
+
this.dials = { ...DEFAULT_WEBHOOK_DIALS, ...deps.dials };
|
|
225
|
+
this.box = createSecretBox(deps.authSecret, WEBHOOK_SECRET_BOX_INFO);
|
|
226
|
+
this.now = deps.now ?? (() => new Date());
|
|
227
|
+
this.deliveries = new Counter({
|
|
228
|
+
name: 'webhook_delivery_attempts_total',
|
|
229
|
+
help: 'Webhook delivery attempts by outcome: delivered, failed (will retry), refused (the endpoint off or the address refused)',
|
|
230
|
+
labelNames: ['outcome'] as const,
|
|
231
|
+
registers: []
|
|
232
|
+
});
|
|
233
|
+
this.held = new Counter({
|
|
234
|
+
name: 'webhook_deliveries_held_total',
|
|
235
|
+
help: 'Webhook deliveries that ran out of retries and were held for an hourly re-drive',
|
|
236
|
+
registers: []
|
|
237
|
+
});
|
|
238
|
+
this.disabled = new Counter({
|
|
239
|
+
name: 'webhook_endpoints_disabled_total',
|
|
240
|
+
help: 'Webhook endpoints switched off after a failure streak',
|
|
241
|
+
registers: []
|
|
242
|
+
});
|
|
243
|
+
this.promMetrics = [this.deliveries, this.held, this.disabled];
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
attach(boss: PgBoss): void {
|
|
247
|
+
this.boss = boss;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
get catalog(): WebhookCatalogIndex {
|
|
251
|
+
return this.deps.catalog;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ── Emit ─────────────────────────────────────────────────────────────────
|
|
255
|
+
|
|
256
|
+
async emit(conn: DbConn, event: WebhookEmit): Promise<string | null> {
|
|
257
|
+
const declared = this.deps.catalog.events.get(event.type);
|
|
258
|
+
if (!declared) {
|
|
259
|
+
// A type the catalog does not know is a programming error, not a runtime condition.
|
|
260
|
+
throw new Error(`webhooks: "${event.type}" is not a declared event type`);
|
|
261
|
+
}
|
|
262
|
+
if (!(await this.wants(event.workspaceId, event.type))) return null;
|
|
263
|
+
const parsed = declared.data.safeParse(event.data);
|
|
264
|
+
if (!parsed.success) {
|
|
265
|
+
this.deps.logger.error(
|
|
266
|
+
{ type: event.type, issues: parsed.error.issues.slice(0, 3) },
|
|
267
|
+
'webhooks: an emit carried data outside its declared schema — skipped'
|
|
268
|
+
);
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
const subjects: Record<string, WebhookSubjectValue> = {};
|
|
272
|
+
for (const key of declared.subjects) {
|
|
273
|
+
const value = event.subjects?.[key];
|
|
274
|
+
if (value === undefined) {
|
|
275
|
+
this.deps.logger.error(
|
|
276
|
+
{ type: event.type, subject: key },
|
|
277
|
+
'webhooks: an emit left out a declared subject — skipped'
|
|
278
|
+
);
|
|
279
|
+
return null;
|
|
280
|
+
}
|
|
281
|
+
const declaredSubject = this.deps.catalog.subjects[key];
|
|
282
|
+
if (declaredSubject?.list === true) {
|
|
283
|
+
subjects[key] = typeof value === 'string' ? [value] : [...value];
|
|
284
|
+
} else if (typeof value === 'string') {
|
|
285
|
+
subjects[key] = value;
|
|
286
|
+
} else {
|
|
287
|
+
this.deps.logger.error(
|
|
288
|
+
{ type: event.type, subject: key },
|
|
289
|
+
'webhooks: an emit gave a list for a subject declared as one id — skipped'
|
|
290
|
+
);
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
const boss = this.requireBoss();
|
|
295
|
+
const job: WebhookEventJob = {
|
|
296
|
+
id: randomUUID(),
|
|
297
|
+
type: event.type,
|
|
298
|
+
occurredAt: this.now().toISOString(),
|
|
299
|
+
workspaceId: event.workspaceId,
|
|
300
|
+
subjects,
|
|
301
|
+
data: parsed.data as Record<string, unknown>
|
|
302
|
+
};
|
|
303
|
+
// The fan-out's own retries are short: it only reads and inserts locally.
|
|
304
|
+
const sent = await sendInTx(boss, conn, WEBHOOK_EVENTS_QUEUE, job, {
|
|
305
|
+
retryLimit: 5,
|
|
306
|
+
retryDelay: 5,
|
|
307
|
+
retryBackoff: true
|
|
308
|
+
});
|
|
309
|
+
if (sent === null) throw new Error(`webhooks: the queue ${WEBHOOK_EVENTS_QUEUE} does not exist`);
|
|
310
|
+
return job.id;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Does any enabled endpoint of the workspace subscribe to this type? Cached per replica. */
|
|
314
|
+
async wants(workspaceId: string, type: string): Promise<boolean> {
|
|
315
|
+
const cached = this.listeners.get(workspaceId);
|
|
316
|
+
const patterns =
|
|
317
|
+
cached && this.now().getTime() - cached.at < this.dials.listenerTtlMs
|
|
318
|
+
? cached.patterns
|
|
319
|
+
: await this.loadListeners(workspaceId);
|
|
320
|
+
return patterns.some((p) => endpointWants({ enabled: true, events: p, filters: {} }, type, {}));
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
private async loadListeners(workspaceId: string): Promise<string[][]> {
|
|
324
|
+
const rows = await this.deps.db
|
|
325
|
+
.select({ events: webhookEndpoints.events })
|
|
326
|
+
.from(webhookEndpoints)
|
|
327
|
+
.where(and(eq(webhookEndpoints.workspaceId, workspaceId), eq(webhookEndpoints.enabled, true)));
|
|
328
|
+
const patterns = rows.map((r) => r.events);
|
|
329
|
+
this.listeners.set(workspaceId, { at: this.now().getTime(), patterns });
|
|
330
|
+
return patterns;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Forget the cached answer of a workspace (every write of its endpoints calls it). */
|
|
334
|
+
invalidateListeners(workspaceId: string): void {
|
|
335
|
+
this.listeners.delete(workspaceId);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* The chassis's own events, bridged from the in-process bus: fired after
|
|
340
|
+
* the write committed, so never for a rolled-back one, and at most once
|
|
341
|
+
* per replica that saw it. The events emitted inside a transaction the
|
|
342
|
+
* chassis owns (projects) do not pass here.
|
|
343
|
+
*/
|
|
344
|
+
bindBus(events: EventBus): void {
|
|
345
|
+
const emit = async (event: WebhookEmit): Promise<void> => {
|
|
346
|
+
try {
|
|
347
|
+
await this.emit(this.deps.db, event);
|
|
348
|
+
} catch (err) {
|
|
349
|
+
this.deps.logger.error({ err, type: event.type }, 'webhooks: a bus-bridged emit failed');
|
|
350
|
+
}
|
|
351
|
+
};
|
|
352
|
+
events.on('member.joined', ({ workspaceId, userId, role }) =>
|
|
353
|
+
emit({ workspaceId, type: 'member.joined', data: { userId, role } })
|
|
354
|
+
);
|
|
355
|
+
events.on('member.removed', ({ workspaceId, userId }) =>
|
|
356
|
+
emit({ workspaceId, type: 'member.removed', data: { userId } })
|
|
357
|
+
);
|
|
358
|
+
events.on('workspace.created', ({ workspaceId, ownerUserId }) =>
|
|
359
|
+
emit({ workspaceId, type: 'workspace.created', data: { ownerUserId } })
|
|
360
|
+
);
|
|
361
|
+
events.on('apikey.created', ({ workspaceId, apiKeyId }) =>
|
|
362
|
+
emit({ workspaceId, type: 'apikey.created', data: { apiKeyId } })
|
|
363
|
+
);
|
|
364
|
+
events.on('apikey.revoked', ({ workspaceId, apiKeyId }) =>
|
|
365
|
+
emit({ workspaceId, type: 'apikey.revoked', data: { apiKeyId } })
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// ── Fan-out ───────────────────────────────────────────────────────────────
|
|
370
|
+
|
|
371
|
+
/** One delivery row and one delivery job per endpoint that wants the event; answers how many. */
|
|
372
|
+
async fanOut(job: WebhookEventJob): Promise<number> {
|
|
373
|
+
const endpoints = await this.deps.db
|
|
374
|
+
.select()
|
|
375
|
+
.from(webhookEndpoints)
|
|
376
|
+
.where(and(eq(webhookEndpoints.workspaceId, job.workspaceId), eq(webhookEndpoints.enabled, true)));
|
|
377
|
+
const matching = endpoints.filter((e) =>
|
|
378
|
+
endpointWants({ enabled: e.enabled, events: e.events, filters: e.filters }, job.type, job.subjects)
|
|
379
|
+
);
|
|
380
|
+
if (matching.length === 0) return 0;
|
|
381
|
+
const payload = this.envelopeOf(job);
|
|
382
|
+
await this.deps.db.transaction(async (tx) => {
|
|
383
|
+
for (const endpoint of matching) {
|
|
384
|
+
const [row] = await tx
|
|
385
|
+
.insert(webhookDeliveries)
|
|
386
|
+
.values({
|
|
387
|
+
endpointId: endpoint.id,
|
|
388
|
+
workspaceId: job.workspaceId,
|
|
389
|
+
eventId: job.id,
|
|
390
|
+
eventType: job.type,
|
|
391
|
+
payload,
|
|
392
|
+
status: 'pending'
|
|
393
|
+
})
|
|
394
|
+
.returning({ id: webhookDeliveries.id });
|
|
395
|
+
await this.queueDelivery(tx, row!.id);
|
|
396
|
+
}
|
|
397
|
+
});
|
|
398
|
+
return matching.length;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** The envelope as the receiver reads it (without `attempt`, stamped at each send). */
|
|
402
|
+
private envelopeOf(job: WebhookEventJob): Record<string, unknown> {
|
|
403
|
+
const subjects: Record<string, unknown> = {};
|
|
404
|
+
for (const [key, value] of Object.entries(job.subjects)) {
|
|
405
|
+
const field = this.deps.catalog.subjects[key]?.field ?? key;
|
|
406
|
+
subjects[field] = value;
|
|
407
|
+
}
|
|
408
|
+
return {
|
|
409
|
+
id: job.id,
|
|
410
|
+
type: job.type,
|
|
411
|
+
occurredAt: job.occurredAt,
|
|
412
|
+
workspaceId: job.workspaceId,
|
|
413
|
+
subjects,
|
|
414
|
+
data: job.data
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
private async queueDelivery(conn: DbConn, deliveryId: string, startAfterSeconds = 0): Promise<void> {
|
|
419
|
+
const sent = await sendInTx(
|
|
420
|
+
this.requireBoss(),
|
|
421
|
+
conn,
|
|
422
|
+
WEBHOOK_DELIVERIES_QUEUE,
|
|
423
|
+
{ deliveryId } satisfies WebhookDeliveryJob,
|
|
424
|
+
{
|
|
425
|
+
retryLimit: this.dials.retryLimit,
|
|
426
|
+
retryDelay: this.dials.retryDelaySeconds,
|
|
427
|
+
retryBackoff: true,
|
|
428
|
+
...(startAfterSeconds > 0 ? { startAfter: startAfterSeconds } : {})
|
|
429
|
+
}
|
|
430
|
+
);
|
|
431
|
+
if (sent === null) throw new Error(`webhooks: the queue ${WEBHOOK_DELIVERIES_QUEUE} does not exist`);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// ── Delivery ──────────────────────────────────────────────────────────────
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* One attempt of a delivery. `retryable` (the queue's worker): a failure
|
|
438
|
+
* throws `WebhookDeliveryFailed` so pg-boss retries. Not retryable (the
|
|
439
|
+
* test ping, run inline): a failure marks the row `failed` and answers it.
|
|
440
|
+
* Answers the row as it is after the attempt.
|
|
441
|
+
*/
|
|
442
|
+
async attempt(deliveryId: string, opts: { retryable: boolean }): Promise<WebhookDeliveryRow | null> {
|
|
443
|
+
const [delivery] = await this.deps.db
|
|
444
|
+
.select()
|
|
445
|
+
.from(webhookDeliveries)
|
|
446
|
+
.where(eq(webhookDeliveries.id, deliveryId))
|
|
447
|
+
.limit(1);
|
|
448
|
+
if (!delivery) return null; // the endpoint (and its rows) are gone
|
|
449
|
+
if (delivery.status === 'delivered') return delivery;
|
|
450
|
+
const [endpoint] = await this.deps.db
|
|
451
|
+
.select()
|
|
452
|
+
.from(webhookEndpoints)
|
|
453
|
+
.where(eq(webhookEndpoints.id, delivery.endpointId))
|
|
454
|
+
.limit(1);
|
|
455
|
+
const now = this.now();
|
|
456
|
+
if (!endpoint || !endpoint.enabled) {
|
|
457
|
+
this.deliveries.inc({ outcome: 'refused' });
|
|
458
|
+
return this.finish(delivery, {
|
|
459
|
+
status: 'failed',
|
|
460
|
+
lastError: 'endpoint_disabled',
|
|
461
|
+
lastStatusCode: null,
|
|
462
|
+
now
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
const attempt = delivery.attempt + 1;
|
|
466
|
+
const body = JSON.stringify({ ...delivery.payload, attempt });
|
|
467
|
+
// The secret is opened INSIDE the attempt's own handling: a row whose
|
|
468
|
+
// secret cannot be opened (a rotated auth secret, a damaged row) ends the
|
|
469
|
+
// delivery as failed `secret_unreadable`, visible on the page, never a
|
|
470
|
+
// throw that loops through the retries with nothing recorded (verifier
|
|
471
|
+
// round 1, MN3).
|
|
472
|
+
let headers: Record<string, string>;
|
|
473
|
+
try {
|
|
474
|
+
const secrets = [this.box.open(endpoint.secret)];
|
|
475
|
+
if (
|
|
476
|
+
endpoint.previousSecret &&
|
|
477
|
+
endpoint.previousSecretExpiresAt &&
|
|
478
|
+
endpoint.previousSecretExpiresAt > now
|
|
479
|
+
) {
|
|
480
|
+
secrets.push(this.box.open(endpoint.previousSecret));
|
|
481
|
+
}
|
|
482
|
+
headers = {
|
|
483
|
+
...webhookHeaders(secrets, delivery.eventId, Math.floor(now.getTime() / 1000), body),
|
|
484
|
+
'user-agent': `${this.deps.identity.slug}-webhooks/${this.deps.version}`
|
|
485
|
+
};
|
|
486
|
+
} catch (e) {
|
|
487
|
+
this.deliveries.inc({ outcome: 'refused' });
|
|
488
|
+
this.deps.logger.error(
|
|
489
|
+
{ err: e, endpointId: endpoint.id, deliveryId: delivery.id },
|
|
490
|
+
'webhooks: the endpoint’s secret cannot be opened — the delivery is failed and the endpoint switched off'
|
|
491
|
+
);
|
|
492
|
+
// An endpoint whose secret never opens would fail every event for ever
|
|
493
|
+
// (verifier round 2, N-R2-2): it is switched off here, with its reason.
|
|
494
|
+
await this.deps.db
|
|
495
|
+
.update(webhookEndpoints)
|
|
496
|
+
.set({ enabled: false, disabledAt: now, disabledReason: 'secret_unreadable', updatedAt: now })
|
|
497
|
+
.where(and(eq(webhookEndpoints.id, endpoint.id), eq(webhookEndpoints.enabled, true)));
|
|
498
|
+
this.invalidateListeners(endpoint.workspaceId);
|
|
499
|
+
return this.finish(delivery, {
|
|
500
|
+
status: 'failed',
|
|
501
|
+
attempt,
|
|
502
|
+
lastError: 'secret_unreadable',
|
|
503
|
+
lastStatusCode: null,
|
|
504
|
+
now
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
if (opts.retryable && (this.inFlight.get(endpoint.id) ?? 0) >= MAX_IN_FLIGHT_PER_ENDPOINT) {
|
|
508
|
+
throw new WebhookDeliveryDeferred(delivery.id);
|
|
509
|
+
}
|
|
510
|
+
this.inFlight.set(endpoint.id, (this.inFlight.get(endpoint.id) ?? 0) + 1);
|
|
511
|
+
let statusCode: number | null = null;
|
|
512
|
+
let error: string | null = null;
|
|
513
|
+
try {
|
|
514
|
+
const res = await safePost(endpoint.url, headers, body, this.deps.policy, {
|
|
515
|
+
timeoutMs: this.dials.timeoutMs,
|
|
516
|
+
...this.deps.fetchOptions
|
|
517
|
+
});
|
|
518
|
+
statusCode = res.status;
|
|
519
|
+
if (res.status < 200 || res.status >= 300) error = `HTTP ${res.status}`;
|
|
520
|
+
} catch (e) {
|
|
521
|
+
error =
|
|
522
|
+
e instanceof WebhookUrlRefusedError
|
|
523
|
+
? `url_refused:${e.code}`
|
|
524
|
+
: (e as Error).name === 'TimeoutError' || (e as Error).name === 'AbortError'
|
|
525
|
+
? 'timeout'
|
|
526
|
+
: String((e as Error).message ?? e).slice(0, MAX_ERROR_TEXT);
|
|
527
|
+
} finally {
|
|
528
|
+
const left = (this.inFlight.get(endpoint.id) ?? 1) - 1;
|
|
529
|
+
if (left <= 0) this.inFlight.delete(endpoint.id);
|
|
530
|
+
else this.inFlight.set(endpoint.id, left);
|
|
531
|
+
}
|
|
532
|
+
if (error === null) {
|
|
533
|
+
this.deliveries.inc({ outcome: 'delivered' });
|
|
534
|
+
await this.deps.db
|
|
535
|
+
.update(webhookEndpoints)
|
|
536
|
+
.set({
|
|
537
|
+
consecutiveFailures: 0,
|
|
538
|
+
firstFailureAt: null,
|
|
539
|
+
lastDeliveryAt: now,
|
|
540
|
+
lastDeliveryStatus: 'delivered',
|
|
541
|
+
updatedAt: now
|
|
542
|
+
})
|
|
543
|
+
.where(eq(webhookEndpoints.id, endpoint.id));
|
|
544
|
+
return this.finish(delivery, {
|
|
545
|
+
status: 'delivered',
|
|
546
|
+
attempt,
|
|
547
|
+
lastStatusCode: statusCode,
|
|
548
|
+
lastError: null,
|
|
549
|
+
now,
|
|
550
|
+
deliveredAt: now
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
this.deliveries.inc({ outcome: 'failed' });
|
|
554
|
+
// The streak is counted IN the statement (never read then written):
|
|
555
|
+
// replicas failing the same endpoint at once each add their one, and the
|
|
556
|
+
// switch-off below is a second conditional statement only one of them
|
|
557
|
+
// wins (verifier round 1, MN5).
|
|
558
|
+
const [counted] = await this.deps.db
|
|
559
|
+
.update(webhookEndpoints)
|
|
560
|
+
.set({
|
|
561
|
+
consecutiveFailures: sql`${webhookEndpoints.consecutiveFailures} + 1`,
|
|
562
|
+
firstFailureAt: sql`COALESCE(${webhookEndpoints.firstFailureAt}, ${now})`,
|
|
563
|
+
lastDeliveryAt: now,
|
|
564
|
+
lastDeliveryStatus: 'failed',
|
|
565
|
+
updatedAt: now
|
|
566
|
+
})
|
|
567
|
+
.where(eq(webhookEndpoints.id, endpoint.id))
|
|
568
|
+
.returning({
|
|
569
|
+
streak: webhookEndpoints.consecutiveFailures,
|
|
570
|
+
firstFailureAt: webhookEndpoints.firstFailureAt
|
|
571
|
+
});
|
|
572
|
+
const streak = counted?.streak ?? endpoint.consecutiveFailures + 1;
|
|
573
|
+
const firstFailureAt = counted?.firstFailureAt ?? now;
|
|
574
|
+
let disable =
|
|
575
|
+
streak >= this.dials.disableAfterFailures &&
|
|
576
|
+
now.getTime() - firstFailureAt.getTime() >= this.dials.disableAfterMs;
|
|
577
|
+
if (disable) {
|
|
578
|
+
const [won] = await this.deps.db
|
|
579
|
+
.update(webhookEndpoints)
|
|
580
|
+
.set({ enabled: false, disabledAt: now, disabledReason: 'delivery_failures', updatedAt: now })
|
|
581
|
+
.where(and(eq(webhookEndpoints.id, endpoint.id), eq(webhookEndpoints.enabled, true)))
|
|
582
|
+
.returning({ id: webhookEndpoints.id });
|
|
583
|
+
disable = won !== undefined;
|
|
584
|
+
}
|
|
585
|
+
if (disable) {
|
|
586
|
+
this.invalidateListeners(endpoint.workspaceId);
|
|
587
|
+
this.disabled.inc();
|
|
588
|
+
this.deps.logger.error(
|
|
589
|
+
{
|
|
590
|
+
endpointId: endpoint.id,
|
|
591
|
+
workspaceId: endpoint.workspaceId,
|
|
592
|
+
streak,
|
|
593
|
+
since: firstFailureAt.toISOString()
|
|
594
|
+
},
|
|
595
|
+
'webhooks: an endpoint was switched off after a failure streak (webhook_endpoints_disabled_total)'
|
|
596
|
+
);
|
|
597
|
+
await this.mailDisabled(endpoint, streak, firstFailureAt).catch((err) =>
|
|
598
|
+
this.deps.logger.error({ err, endpointId: endpoint.id }, 'webhooks: the auto-disable mail failed')
|
|
599
|
+
);
|
|
600
|
+
}
|
|
601
|
+
const nextAttemptAt =
|
|
602
|
+
opts.retryable && attempt <= this.dials.retryLimit
|
|
603
|
+
? new Date(now.getTime() + this.dials.retryDelaySeconds * 2 ** (attempt - 1) * 1000)
|
|
604
|
+
: null;
|
|
605
|
+
const row = await this.finish(delivery, {
|
|
606
|
+
status: opts.retryable ? 'pending' : 'failed',
|
|
607
|
+
attempt,
|
|
608
|
+
lastStatusCode: statusCode,
|
|
609
|
+
lastError: error,
|
|
610
|
+
nextAttemptAt,
|
|
611
|
+
now
|
|
612
|
+
});
|
|
613
|
+
if (opts.retryable) throw new WebhookDeliveryFailed(delivery.id, error);
|
|
614
|
+
return row;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
private async finish(
|
|
618
|
+
delivery: WebhookDeliveryRow,
|
|
619
|
+
patch: {
|
|
620
|
+
status: WebhookDeliveryRow['status'];
|
|
621
|
+
attempt?: number;
|
|
622
|
+
lastStatusCode: number | null;
|
|
623
|
+
lastError: string | null;
|
|
624
|
+
nextAttemptAt?: Date | null;
|
|
625
|
+
deliveredAt?: Date;
|
|
626
|
+
now: Date;
|
|
627
|
+
}
|
|
628
|
+
): Promise<WebhookDeliveryRow> {
|
|
629
|
+
const [row] = await this.deps.db
|
|
630
|
+
.update(webhookDeliveries)
|
|
631
|
+
.set({
|
|
632
|
+
status: patch.status,
|
|
633
|
+
attempt: patch.attempt ?? delivery.attempt,
|
|
634
|
+
lastStatusCode: patch.lastStatusCode,
|
|
635
|
+
lastError: patch.lastError,
|
|
636
|
+
nextAttemptAt: patch.nextAttemptAt ?? null,
|
|
637
|
+
deliveredAt: patch.deliveredAt ?? null,
|
|
638
|
+
updatedAt: patch.now
|
|
639
|
+
})
|
|
640
|
+
.where(eq(webhookDeliveries.id, delivery.id))
|
|
641
|
+
.returning();
|
|
642
|
+
return row!;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/** Put a delivery back in the queue after `inSeconds` (a deferred attempt). */
|
|
646
|
+
async requeue(deliveryId: string, inSeconds: number): Promise<void> {
|
|
647
|
+
await this.queueDelivery(this.deps.db, deliveryId, inSeconds);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/**
|
|
651
|
+
* The held queue's work: mark the row held and queue it again after the
|
|
652
|
+
* hold, in ONE transaction, so a re-send that fails leaves the row pending
|
|
653
|
+
* and the held job's own retry finds it again (the chassis suite's round 1:
|
|
654
|
+
* a row marked held outside the transaction was never re-driven once its
|
|
655
|
+
* send had failed). A row already delivered, failed or gone is left alone.
|
|
656
|
+
*/
|
|
657
|
+
async hold(deliveryId: string): Promise<void> {
|
|
658
|
+
const now = this.now();
|
|
659
|
+
const row = await this.deps.db.transaction(async (tx) => {
|
|
660
|
+
const [updated] = await tx
|
|
661
|
+
.update(webhookDeliveries)
|
|
662
|
+
.set({
|
|
663
|
+
status: 'held',
|
|
664
|
+
nextAttemptAt: new Date(now.getTime() + this.dials.heldDelaySeconds * 1000),
|
|
665
|
+
updatedAt: now
|
|
666
|
+
})
|
|
667
|
+
.where(and(eq(webhookDeliveries.id, deliveryId), eq(webhookDeliveries.status, 'pending')))
|
|
668
|
+
.returning({ id: webhookDeliveries.id, endpointId: webhookDeliveries.endpointId });
|
|
669
|
+
if (!updated) return null;
|
|
670
|
+
await this.queueDelivery(tx, deliveryId, this.dials.heldDelaySeconds);
|
|
671
|
+
return updated;
|
|
672
|
+
});
|
|
673
|
+
if (!row) return;
|
|
674
|
+
// Counted and logged once the hold committed: a rolled-back hold is not one.
|
|
675
|
+
this.held.inc();
|
|
676
|
+
this.deps.logger.error(
|
|
677
|
+
{ deliveryId, endpointId: row.endpointId, retryAfterSeconds: this.dials.heldDelaySeconds },
|
|
678
|
+
'webhooks: a delivery exhausted its retries — held, re-driven after the hold (webhook_deliveries_held_total)'
|
|
679
|
+
);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
private async mailDisabled(endpoint: WebhookEndpointRow, streak: number, since: Date): Promise<void> {
|
|
683
|
+
if (!endpoint.createdBy || !this.deps.email.delivers) return;
|
|
684
|
+
// Only a person still an ACTIVE member of the workspace is told: a
|
|
685
|
+
// creator who left takes no workspace name and no link with them
|
|
686
|
+
// (verifier round 1, MN4).
|
|
687
|
+
const [creator] = await this.deps.db
|
|
688
|
+
.select({ email: userTable.email, name: userTable.name })
|
|
689
|
+
.from(userTable)
|
|
690
|
+
.innerJoin(
|
|
691
|
+
workspaceMembers,
|
|
692
|
+
and(
|
|
693
|
+
eq(workspaceMembers.userId, userTable.id),
|
|
694
|
+
eq(workspaceMembers.workspaceId, endpoint.workspaceId),
|
|
695
|
+
eq(workspaceMembers.isActive, true)
|
|
696
|
+
)
|
|
697
|
+
)
|
|
698
|
+
.where(eq(userTable.id, endpoint.createdBy))
|
|
699
|
+
.limit(1);
|
|
700
|
+
if (!creator) return;
|
|
701
|
+
const [workspace] = await this.deps.db
|
|
702
|
+
.select({ name: workspaces.name })
|
|
703
|
+
.from(workspaces)
|
|
704
|
+
.where(eq(workspaces.id, endpoint.workspaceId))
|
|
705
|
+
.limit(1);
|
|
706
|
+
const mail = buildWebhookDisabledEmail({
|
|
707
|
+
brand: this.deps.brand,
|
|
708
|
+
recipientName: creator.name,
|
|
709
|
+
url: endpoint.url,
|
|
710
|
+
description: endpoint.description,
|
|
711
|
+
workspaceName: workspace?.name ?? 'your workspace',
|
|
712
|
+
consecutiveFailures: streak,
|
|
713
|
+
firstFailureAt: since,
|
|
714
|
+
manageUrl: `${this.deps.publicBaseUrl}/webhooks?endpoint=${endpoint.id}`
|
|
715
|
+
});
|
|
716
|
+
await this.deps.email.send({ to: creator.email, ...mail });
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/** The nightly purge: deliveries older than `retentionDays`, in bounded batches. */
|
|
720
|
+
async purge(retentionDays: number): Promise<number> {
|
|
721
|
+
if (retentionDays <= 0) return 0;
|
|
722
|
+
const BATCH = 5000;
|
|
723
|
+
let total = 0;
|
|
724
|
+
for (let i = 0; i < 10_000; i++) {
|
|
725
|
+
const res = await this.deps.db.execute(sql`
|
|
726
|
+
DELETE FROM webhook_deliveries
|
|
727
|
+
WHERE id IN (
|
|
728
|
+
SELECT id FROM webhook_deliveries
|
|
729
|
+
WHERE created_at < now() - make_interval(days => ${retentionDays})
|
|
730
|
+
ORDER BY created_at
|
|
731
|
+
LIMIT ${BATCH}
|
|
732
|
+
)
|
|
733
|
+
`);
|
|
734
|
+
const deleted = res.rowCount ?? 0;
|
|
735
|
+
total += deleted;
|
|
736
|
+
if (deleted < BATCH) break;
|
|
737
|
+
}
|
|
738
|
+
return total;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// ── Endpoints ─────────────────────────────────────────────────────────────
|
|
742
|
+
|
|
743
|
+
/** The URL rule at registration (the address itself is judged at every delivery). */
|
|
744
|
+
checkUrl(url: string): void {
|
|
745
|
+
try {
|
|
746
|
+
checkWebhookUrl(url, this.deps.policy);
|
|
747
|
+
} catch (e) {
|
|
748
|
+
if (e instanceof WebhookUrlRefusedError) throw new WebhookRefusedError('url_refused', e.message);
|
|
749
|
+
throw e;
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
/**
|
|
754
|
+
* The subscription is checked against the catalog at registration
|
|
755
|
+
* (verifier round 1, MN1): an exact type must be declared, a family must
|
|
756
|
+
* have at least one declared type, `*` is always right; every filter key
|
|
757
|
+
* must be a declared subject. A typo would otherwise subscribe to nothing,
|
|
758
|
+
* silently.
|
|
759
|
+
*/
|
|
760
|
+
checkSubscription(
|
|
761
|
+
events: readonly string[] | undefined,
|
|
762
|
+
filters: Record<string, string[]> | undefined
|
|
763
|
+
): void {
|
|
764
|
+
const known = this.deps.catalog.events;
|
|
765
|
+
const families = new Set([...known.keys()].map((t) => t.slice(0, t.indexOf('.'))));
|
|
766
|
+
for (const pattern of events ?? []) {
|
|
767
|
+
if (pattern === '*') continue;
|
|
768
|
+
if (pattern.endsWith('.*')) {
|
|
769
|
+
if (!families.has(pattern.slice(0, -2))) {
|
|
770
|
+
throw new WebhookRefusedError(
|
|
771
|
+
'unknown_event',
|
|
772
|
+
`"${pattern}" matches no event family of this instance`
|
|
773
|
+
);
|
|
774
|
+
}
|
|
775
|
+
} else if (!known.has(pattern)) {
|
|
776
|
+
throw new WebhookRefusedError('unknown_event', `"${pattern}" is not an event type of this instance`);
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
for (const key of Object.keys(filters ?? {})) {
|
|
780
|
+
if (!(key in this.deps.catalog.subjects)) {
|
|
781
|
+
throw new WebhookRefusedError('unknown_subject', `"${key}" is not a subject of this instance`);
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
async create(
|
|
787
|
+
workspaceId: string,
|
|
788
|
+
createdBy: string,
|
|
789
|
+
input: WebhookEndpointCreate
|
|
790
|
+
): Promise<{ endpoint: WebhookEndpoint; secret: string }> {
|
|
791
|
+
this.checkUrl(input.url);
|
|
792
|
+
this.checkSubscription(input.events, input.filters);
|
|
793
|
+
const secret = generateWebhookSecret();
|
|
794
|
+
const [row] = await this.deps.db
|
|
795
|
+
.insert(webhookEndpoints)
|
|
796
|
+
.values({
|
|
797
|
+
workspaceId,
|
|
798
|
+
createdBy,
|
|
799
|
+
url: input.url,
|
|
800
|
+
description: input.description ?? '',
|
|
801
|
+
events: [...input.events],
|
|
802
|
+
filters: normalizeFilters(input.filters),
|
|
803
|
+
enabled: input.enabled ?? true,
|
|
804
|
+
secret: this.box.seal(secret)
|
|
805
|
+
})
|
|
806
|
+
.returning();
|
|
807
|
+
this.invalidateListeners(workspaceId);
|
|
808
|
+
return { endpoint: endpointToWire(row!), secret };
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
async get(workspaceId: string, id: string): Promise<WebhookEndpoint | null> {
|
|
812
|
+
const row = await this.row(workspaceId, id);
|
|
813
|
+
return row ? endpointToWire(row) : null;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
async list(
|
|
817
|
+
workspaceId: string,
|
|
818
|
+
cursor: string | undefined,
|
|
819
|
+
limit: number
|
|
820
|
+
): Promise<{ webhooks: WebhookEndpoint[]; nextCursor: string | null }> {
|
|
821
|
+
const after = decodeKeysetCursor(cursor);
|
|
822
|
+
const rows = await this.deps.db
|
|
823
|
+
.select({ row: webhookEndpoints, createdAtText: createdAtText(webhookEndpoints.createdAt) })
|
|
824
|
+
.from(webhookEndpoints)
|
|
825
|
+
.where(
|
|
826
|
+
and(
|
|
827
|
+
eq(webhookEndpoints.workspaceId, workspaceId),
|
|
828
|
+
...(after
|
|
829
|
+
? [
|
|
830
|
+
keysetBeforeValue({
|
|
831
|
+
id: webhookEndpoints.id,
|
|
832
|
+
createdAt: webhookEndpoints.createdAt,
|
|
833
|
+
cursor: after
|
|
834
|
+
})
|
|
835
|
+
]
|
|
836
|
+
: [])
|
|
837
|
+
)
|
|
838
|
+
)
|
|
839
|
+
.orderBy(desc(webhookEndpoints.createdAt), desc(webhookEndpoints.id))
|
|
840
|
+
.limit(limit + 1);
|
|
841
|
+
const page = rows.slice(0, limit);
|
|
842
|
+
const last = rows.length > limit ? page[page.length - 1] : undefined;
|
|
843
|
+
return {
|
|
844
|
+
webhooks: page.map((r) => endpointToWire(r.row)),
|
|
845
|
+
nextCursor: last ? encodeKeysetCursor({ id: last.row.id, createdAtText: last.createdAtText }) : null
|
|
846
|
+
};
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
/** Endpoints of the workspace, for the plan limit's count hook. */
|
|
850
|
+
async count(workspaceId: string): Promise<number> {
|
|
851
|
+
const [row] = await this.deps.db
|
|
852
|
+
.select({ n: sql<number>`count(*)::int` })
|
|
853
|
+
.from(webhookEndpoints)
|
|
854
|
+
.where(eq(webhookEndpoints.workspaceId, workspaceId));
|
|
855
|
+
return Number(row?.n ?? 0);
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
async update(
|
|
859
|
+
workspaceId: string,
|
|
860
|
+
id: string,
|
|
861
|
+
patch: WebhookEndpointUpdate
|
|
862
|
+
): Promise<WebhookEndpoint | null> {
|
|
863
|
+
if (patch.url !== undefined) this.checkUrl(patch.url);
|
|
864
|
+
this.checkSubscription(patch.events, patch.filters);
|
|
865
|
+
const now = this.now();
|
|
866
|
+
const reEnabled = patch.enabled === true;
|
|
867
|
+
const [row] = await this.deps.db
|
|
868
|
+
.update(webhookEndpoints)
|
|
869
|
+
.set({
|
|
870
|
+
...(patch.url !== undefined ? { url: patch.url } : {}),
|
|
871
|
+
...(patch.description !== undefined ? { description: patch.description } : {}),
|
|
872
|
+
...(patch.events !== undefined ? { events: [...patch.events] } : {}),
|
|
873
|
+
...(patch.filters !== undefined ? { filters: normalizeFilters(patch.filters) } : {}),
|
|
874
|
+
...(patch.enabled !== undefined ? { enabled: patch.enabled } : {}),
|
|
875
|
+
// Switching an endpoint back on clears the auto-disable and its streak.
|
|
876
|
+
...(reEnabled
|
|
877
|
+
? { disabledAt: null, disabledReason: null, consecutiveFailures: 0, firstFailureAt: null }
|
|
878
|
+
: {}),
|
|
879
|
+
updatedAt: now
|
|
880
|
+
})
|
|
881
|
+
.where(and(eq(webhookEndpoints.id, id), eq(webhookEndpoints.workspaceId, workspaceId)))
|
|
882
|
+
.returning();
|
|
883
|
+
this.invalidateListeners(workspaceId);
|
|
884
|
+
return row ? endpointToWire(row) : null;
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
async remove(workspaceId: string, id: string): Promise<WebhookEndpoint | null> {
|
|
888
|
+
const [row] = await this.deps.db
|
|
889
|
+
.delete(webhookEndpoints)
|
|
890
|
+
.where(and(eq(webhookEndpoints.id, id), eq(webhookEndpoints.workspaceId, workspaceId)))
|
|
891
|
+
.returning();
|
|
892
|
+
this.invalidateListeners(workspaceId);
|
|
893
|
+
return row ? endpointToWire(row) : null;
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
async rotateSecret(
|
|
897
|
+
workspaceId: string,
|
|
898
|
+
id: string
|
|
899
|
+
): Promise<{ endpoint: WebhookEndpoint; secret: string; previousSecretExpiresAt: string } | null> {
|
|
900
|
+
const current = await this.row(workspaceId, id);
|
|
901
|
+
if (!current) return null;
|
|
902
|
+
const now = this.now();
|
|
903
|
+
const secret = generateWebhookSecret();
|
|
904
|
+
const previousSecretExpiresAt = new Date(now.getTime() + this.dials.previousSecretOverlapMs);
|
|
905
|
+
const [row] = await this.deps.db
|
|
906
|
+
.update(webhookEndpoints)
|
|
907
|
+
.set({
|
|
908
|
+
secret: this.box.seal(secret),
|
|
909
|
+
previousSecret: current.secret,
|
|
910
|
+
previousSecretExpiresAt,
|
|
911
|
+
updatedAt: now
|
|
912
|
+
})
|
|
913
|
+
.where(eq(webhookEndpoints.id, id))
|
|
914
|
+
.returning();
|
|
915
|
+
return {
|
|
916
|
+
endpoint: endpointToWire(row!),
|
|
917
|
+
secret,
|
|
918
|
+
previousSecretExpiresAt: previousSecretExpiresAt.toISOString()
|
|
919
|
+
};
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
/** A signed `webhook.ping`, attempted inline, once; the row says how it went. */
|
|
923
|
+
async test(workspaceId: string, id: string, sentBy: string | null): Promise<WebhookDelivery | null> {
|
|
924
|
+
const endpoint = await this.row(workspaceId, id);
|
|
925
|
+
if (!endpoint) return null;
|
|
926
|
+
const job: WebhookEventJob = {
|
|
927
|
+
id: randomUUID(),
|
|
928
|
+
type: WEBHOOK_PING_EVENT,
|
|
929
|
+
occurredAt: this.now().toISOString(),
|
|
930
|
+
workspaceId,
|
|
931
|
+
subjects: {},
|
|
932
|
+
data: { endpointId: id, sentBy }
|
|
933
|
+
};
|
|
934
|
+
const [row] = await this.deps.db
|
|
935
|
+
.insert(webhookDeliveries)
|
|
936
|
+
.values({
|
|
937
|
+
endpointId: id,
|
|
938
|
+
workspaceId,
|
|
939
|
+
eventId: job.id,
|
|
940
|
+
eventType: job.type,
|
|
941
|
+
payload: this.envelopeOf(job),
|
|
942
|
+
status: 'pending'
|
|
943
|
+
})
|
|
944
|
+
.returning({ id: webhookDeliveries.id });
|
|
945
|
+
const result = await this.attempt(row!.id, { retryable: false });
|
|
946
|
+
return result ? deliveryToWire(result) : null;
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
async listDeliveries(
|
|
950
|
+
workspaceId: string,
|
|
951
|
+
id: string,
|
|
952
|
+
query: { cursor?: string | undefined; limit: number; status?: WebhookDeliveryRow['status'] | undefined }
|
|
953
|
+
): Promise<{ deliveries: WebhookDelivery[]; nextCursor: string | null } | null> {
|
|
954
|
+
const endpoint = await this.row(workspaceId, id);
|
|
955
|
+
if (!endpoint) return null;
|
|
956
|
+
const after = decodeKeysetCursor(query.cursor);
|
|
957
|
+
const rows = await this.deps.db
|
|
958
|
+
.select({ row: webhookDeliveries, createdAtText: createdAtText(webhookDeliveries.createdAt) })
|
|
959
|
+
.from(webhookDeliveries)
|
|
960
|
+
.where(
|
|
961
|
+
and(
|
|
962
|
+
eq(webhookDeliveries.endpointId, id),
|
|
963
|
+
...(query.status ? [eq(webhookDeliveries.status, query.status)] : []),
|
|
964
|
+
...(after
|
|
965
|
+
? [
|
|
966
|
+
keysetBeforeValue({
|
|
967
|
+
id: webhookDeliveries.id,
|
|
968
|
+
createdAt: webhookDeliveries.createdAt,
|
|
969
|
+
cursor: after
|
|
970
|
+
})
|
|
971
|
+
]
|
|
972
|
+
: [])
|
|
973
|
+
)
|
|
974
|
+
)
|
|
975
|
+
.orderBy(desc(webhookDeliveries.createdAt), desc(webhookDeliveries.id))
|
|
976
|
+
.limit(query.limit + 1);
|
|
977
|
+
const page = rows.slice(0, query.limit);
|
|
978
|
+
const last = rows.length > query.limit ? page[page.length - 1] : undefined;
|
|
979
|
+
return {
|
|
980
|
+
deliveries: page.map((r) => deliveryToWire(r.row)),
|
|
981
|
+
nextCursor: last ? encodeKeysetCursor({ id: last.row.id, createdAtText: last.createdAtText }) : null
|
|
982
|
+
};
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
async getDelivery(workspaceId: string, id: string, deliveryId: string): Promise<WebhookDelivery | null> {
|
|
986
|
+
const row = await this.deliveryRow(workspaceId, id, deliveryId);
|
|
987
|
+
return row ? deliveryToWire(row) : null;
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
/** The same event queued again for the endpoint, as a new row (the event id unchanged: the receiver's dedupe key). */
|
|
991
|
+
async redeliver(workspaceId: string, id: string, deliveryId: string): Promise<WebhookDelivery | null> {
|
|
992
|
+
const source = await this.deliveryRow(workspaceId, id, deliveryId);
|
|
993
|
+
if (!source) return null;
|
|
994
|
+
const row = await this.deps.db.transaction(async (tx) => {
|
|
995
|
+
const [inserted] = await tx
|
|
996
|
+
.insert(webhookDeliveries)
|
|
997
|
+
.values({
|
|
998
|
+
endpointId: source.endpointId,
|
|
999
|
+
workspaceId: source.workspaceId,
|
|
1000
|
+
eventId: source.eventId,
|
|
1001
|
+
eventType: source.eventType,
|
|
1002
|
+
payload: source.payload,
|
|
1003
|
+
status: 'pending'
|
|
1004
|
+
})
|
|
1005
|
+
.returning();
|
|
1006
|
+
await this.queueDelivery(tx, inserted!.id);
|
|
1007
|
+
return inserted!;
|
|
1008
|
+
});
|
|
1009
|
+
return deliveryToWire(row);
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
private async row(workspaceId: string, id: string): Promise<WebhookEndpointRow | undefined> {
|
|
1013
|
+
const [row] = await this.deps.db
|
|
1014
|
+
.select()
|
|
1015
|
+
.from(webhookEndpoints)
|
|
1016
|
+
.where(and(eq(webhookEndpoints.id, id), eq(webhookEndpoints.workspaceId, workspaceId)))
|
|
1017
|
+
.limit(1);
|
|
1018
|
+
return row;
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
private async deliveryRow(
|
|
1022
|
+
workspaceId: string,
|
|
1023
|
+
id: string,
|
|
1024
|
+
deliveryId: string
|
|
1025
|
+
): Promise<WebhookDeliveryRow | undefined> {
|
|
1026
|
+
const [row] = await this.deps.db
|
|
1027
|
+
.select()
|
|
1028
|
+
.from(webhookDeliveries)
|
|
1029
|
+
.where(
|
|
1030
|
+
and(
|
|
1031
|
+
eq(webhookDeliveries.id, deliveryId),
|
|
1032
|
+
eq(webhookDeliveries.endpointId, id),
|
|
1033
|
+
eq(webhookDeliveries.workspaceId, workspaceId)
|
|
1034
|
+
)
|
|
1035
|
+
)
|
|
1036
|
+
.limit(1);
|
|
1037
|
+
return row;
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
private requireBoss(): PgBoss {
|
|
1041
|
+
if (!this.boss) throw new Error('webhooks: the job runtime is not attached yet (boot order)');
|
|
1042
|
+
return this.boss;
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
/** Filters as stored: every key kept, its ids deduplicated; an empty object when none. */
|
|
1047
|
+
function normalizeFilters(filters: Record<string, string[]> | undefined): Record<string, string[]> {
|
|
1048
|
+
const out: Record<string, string[]> = {};
|
|
1049
|
+
for (const [key, ids] of Object.entries(filters ?? {})) out[key] = [...new Set(ids)];
|
|
1050
|
+
return out;
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
export function endpointToWire(row: WebhookEndpointRow): WebhookEndpoint {
|
|
1054
|
+
return {
|
|
1055
|
+
id: row.id,
|
|
1056
|
+
url: row.url,
|
|
1057
|
+
description: row.description,
|
|
1058
|
+
events: row.events,
|
|
1059
|
+
filters: row.filters,
|
|
1060
|
+
enabled: row.enabled,
|
|
1061
|
+
createdBy: row.createdBy,
|
|
1062
|
+
createdAt: row.createdAt.toISOString(),
|
|
1063
|
+
updatedAt: row.updatedAt.toISOString(),
|
|
1064
|
+
disabledAt: row.disabledAt?.toISOString() ?? null,
|
|
1065
|
+
disabledReason: row.disabledReason,
|
|
1066
|
+
consecutiveFailures: row.consecutiveFailures,
|
|
1067
|
+
lastDeliveryAt: row.lastDeliveryAt?.toISOString() ?? null,
|
|
1068
|
+
lastDeliveryStatus: row.lastDeliveryStatus,
|
|
1069
|
+
previousSecretExpiresAt: row.previousSecretExpiresAt?.toISOString() ?? null
|
|
1070
|
+
};
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
export function deliveryToWire(row: WebhookDeliveryRow): WebhookDelivery {
|
|
1074
|
+
return {
|
|
1075
|
+
id: row.id,
|
|
1076
|
+
endpointId: row.endpointId,
|
|
1077
|
+
eventId: row.eventId,
|
|
1078
|
+
eventType: row.eventType,
|
|
1079
|
+
status: row.status,
|
|
1080
|
+
attempt: row.attempt,
|
|
1081
|
+
lastStatusCode: row.lastStatusCode,
|
|
1082
|
+
lastError: row.lastError,
|
|
1083
|
+
nextAttemptAt: row.nextAttemptAt?.toISOString() ?? null,
|
|
1084
|
+
deliveredAt: row.deliveredAt?.toISOString() ?? null,
|
|
1085
|
+
createdAt: row.createdAt.toISOString(),
|
|
1086
|
+
updatedAt: row.updatedAt.toISOString(),
|
|
1087
|
+
payload: row.payload
|
|
1088
|
+
};
|
|
1089
|
+
}
|