@objectstack/plugin-webhooks 5.1.0 → 6.0.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/.turbo/turbo-build.log +35 -13
- package/CHANGELOG.md +17 -33
- package/dist/chunk-33LYZT7O.js +184 -0
- package/dist/chunk-33LYZT7O.js.map +1 -0
- package/dist/chunk-BS2QTZH3.js +256 -0
- package/dist/chunk-BS2QTZH3.js.map +1 -0
- package/dist/chunk-FA66GQEO.cjs +256 -0
- package/dist/chunk-FA66GQEO.cjs.map +1 -0
- package/dist/chunk-MJZGD37S.cjs +184 -0
- package/dist/chunk-MJZGD37S.cjs.map +1 -0
- package/dist/index.cjs +908 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +469 -0
- package/dist/index.d.ts +435 -70
- package/dist/index.js +872 -217
- package/dist/index.js.map +1 -1
- package/dist/outbox-CIn7LSyB.d.cts +155 -0
- package/dist/outbox-CIn7LSyB.d.ts +155 -0
- package/dist/schema.cjs +9 -0
- package/dist/schema.cjs.map +1 -0
- package/dist/schema.d.cts +4787 -0
- package/dist/schema.d.ts +4787 -0
- package/dist/schema.js +9 -0
- package/dist/schema.js.map +1 -0
- package/dist/sql-outbox.cjs +8 -0
- package/dist/sql-outbox.cjs.map +1 -0
- package/dist/sql-outbox.d.cts +55 -0
- package/dist/sql-outbox.d.ts +55 -0
- package/dist/sql-outbox.js +8 -0
- package/dist/sql-outbox.js.map +1 -0
- package/package.json +30 -10
- package/src/auto-enqueuer.test.ts +391 -0
- package/src/auto-enqueuer.ts +335 -0
- package/src/dispatcher.test.ts +324 -0
- package/src/dispatcher.ts +218 -0
- package/src/http-sender.ts +187 -0
- package/src/index.ts +49 -12
- package/src/memory-outbox.test.ts +86 -0
- package/src/memory-outbox.ts +155 -0
- package/src/outbox.ts +175 -0
- package/src/partition.ts +19 -0
- package/src/retention.test.ts +116 -0
- package/src/retention.ts +144 -0
- package/src/schema.ts +22 -0
- package/src/sql-outbox.test.ts +490 -0
- package/src/sql-outbox.ts +343 -0
- package/src/sys-webhook-delivery.object.ts +224 -0
- package/src/webhook-outbox-plugin.ts +442 -0
- package/tsconfig.json +5 -13
- package/tsup.config.ts +14 -0
- package/dist/index.d.mts +0 -104
- package/dist/index.mjs +0 -216
- package/dist/index.mjs.map +0 -1
- package/src/webhooks-plugin.test.ts +0 -218
- package/src/webhooks-plugin.ts +0 -294
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
+
|
|
3
|
+
import type { Plugin, PluginContext } from '@objectstack/core';
|
|
4
|
+
import type {
|
|
5
|
+
IClusterService,
|
|
6
|
+
IDataEngine,
|
|
7
|
+
IRealtimeService,
|
|
8
|
+
} from '@objectstack/spec/contracts';
|
|
9
|
+
import { SysWebhook } from '@objectstack/platform-objects/integration';
|
|
10
|
+
import { AutoEnqueuer, type AutoEnqueuerOptions } from './auto-enqueuer.js';
|
|
11
|
+
import { WebhookDispatcher, type DispatcherOptions } from './dispatcher.js';
|
|
12
|
+
import { MemoryWebhookOutbox } from './memory-outbox.js';
|
|
13
|
+
import type { IWebhookOutbox } from './outbox.js';
|
|
14
|
+
import {
|
|
15
|
+
DeliveryRetentionSweeper,
|
|
16
|
+
type DeliveryRetentionOptions,
|
|
17
|
+
} from './retention.js';
|
|
18
|
+
import { SqlWebhookOutbox } from './sql-outbox.js';
|
|
19
|
+
import { SysWebhookDelivery } from './sys-webhook-delivery.object.js';
|
|
20
|
+
|
|
21
|
+
export interface WebhookOutboxPluginOptions
|
|
22
|
+
extends Partial<Omit<DispatcherOptions, 'cluster' | 'outbox' | 'nodeId'>> {
|
|
23
|
+
/**
|
|
24
|
+
* Override the outbox backend. If omitted a fresh `MemoryWebhookOutbox`
|
|
25
|
+
* is used — fine for local development, **not for production**: each
|
|
26
|
+
* node will see only its own rows.
|
|
27
|
+
*
|
|
28
|
+
* Pass a factory if you need the kernel-resolved `IDataEngine`:
|
|
29
|
+
*
|
|
30
|
+
* ```ts
|
|
31
|
+
* outbox: (ctx) => new SqlWebhookOutbox(
|
|
32
|
+
* ctx.getService('objectql'), { partitionCount: 8 },
|
|
33
|
+
* ),
|
|
34
|
+
* ```
|
|
35
|
+
*/
|
|
36
|
+
outbox?: IWebhookOutbox | ((ctx: PluginContext) => IWebhookOutbox);
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Stable node id. If omitted, uses `process.env.OBJECTSTACK_NODE_ID`
|
|
40
|
+
* or a random UUID generated at plugin init.
|
|
41
|
+
*/
|
|
42
|
+
nodeId?: string;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* If `false`, the plugin registers the outbox/dispatcher services but
|
|
46
|
+
* does NOT auto-start the loop — useful for tests that want to step
|
|
47
|
+
* the dispatcher manually via `dispatcher.tick()`.
|
|
48
|
+
*
|
|
49
|
+
* Default: true.
|
|
50
|
+
*/
|
|
51
|
+
autoStart?: boolean;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Auto-enqueue config. When enabled (default `true` if the realtime
|
|
55
|
+
* + data engine services are available), the plugin subscribes to
|
|
56
|
+
* `data.record.*` events emitted by the engine and automatically
|
|
57
|
+
* enqueues a delivery row for every matching `sys_webhook` row.
|
|
58
|
+
*
|
|
59
|
+
* Set `false` to disable and only use the imperative
|
|
60
|
+
* `outbox.enqueue()` API.
|
|
61
|
+
*/
|
|
62
|
+
autoEnqueue?: boolean | AutoEnqueuerOptions;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Retention sweep config. When enabled (default `true` if a SQL
|
|
66
|
+
* outbox is in use), a periodic timer prunes old `success` and
|
|
67
|
+
* `dead` rows from `sys_webhook_delivery`.
|
|
68
|
+
*
|
|
69
|
+
* Set `false` to disable (e.g. when using `MemoryWebhookOutbox`).
|
|
70
|
+
*/
|
|
71
|
+
retention?: boolean | DeliveryRetentionOptions;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Wires a persistent, cluster-aware webhook outbox into the kernel.
|
|
76
|
+
*
|
|
77
|
+
* Registered services:
|
|
78
|
+
* - `webhook.outbox` → `IWebhookOutbox` (enqueue / claim / ack / list)
|
|
79
|
+
* - `webhook.dispatcher` → `WebhookDispatcher` (manual `tick()` if needed)
|
|
80
|
+
* - `webhook.autoEnqueuer` → `AutoEnqueuer` when auto-enqueue is on
|
|
81
|
+
* - `webhook.retention` → `DeliveryRetentionSweeper` when retention is on
|
|
82
|
+
*
|
|
83
|
+
* End-to-end flow once auto-enqueue is enabled:
|
|
84
|
+
*
|
|
85
|
+
* engine.insert('contact', {...})
|
|
86
|
+
* → engine publishes data.record.created via IRealtimeService
|
|
87
|
+
* → AutoEnqueuer matches active sys_webhook rows in O(1)
|
|
88
|
+
* → outbox.enqueue() runs fire-and-forget (not on the write path)
|
|
89
|
+
* → dispatcher claims and POSTs (cluster-coordinated)
|
|
90
|
+
*
|
|
91
|
+
* **Cluster requirement** — this plugin depends on the cluster service
|
|
92
|
+
* (`ClusterServicePlugin`). With the default `memory` driver the
|
|
93
|
+
* dispatcher works correctly inside a single process; with a real driver
|
|
94
|
+
* (`@objectstack/service-cluster-redis`) it correctly coordinates work
|
|
95
|
+
* across nodes.
|
|
96
|
+
*/
|
|
97
|
+
export class WebhookOutboxPlugin implements Plugin {
|
|
98
|
+
name = 'com.objectstack.plugin-webhook-outbox';
|
|
99
|
+
version = '1.1.0';
|
|
100
|
+
type = 'standard' as const;
|
|
101
|
+
dependencies = ['com.objectstack.service.cluster'];
|
|
102
|
+
|
|
103
|
+
private dispatcher: WebhookDispatcher | undefined;
|
|
104
|
+
private autoEnqueuer: AutoEnqueuer | undefined;
|
|
105
|
+
private retention: DeliveryRetentionSweeper | undefined;
|
|
106
|
+
private outboxInstance: IWebhookOutbox | undefined;
|
|
107
|
+
|
|
108
|
+
constructor(private readonly options: WebhookOutboxPluginOptions = {}) {}
|
|
109
|
+
|
|
110
|
+
async init(ctx: PluginContext): Promise<void> {
|
|
111
|
+
const cluster = ctx.getService<IClusterService>('cluster');
|
|
112
|
+
if (!cluster) {
|
|
113
|
+
throw new Error(
|
|
114
|
+
'WebhookOutboxPlugin: required service "cluster" not found — register ClusterServicePlugin first',
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Register the schemas this plugin owns at runtime. `sys_webhook`
|
|
119
|
+
// (config) lives in @objectstack/platform-objects but no other
|
|
120
|
+
// plugin claims it — the webhook plugin is the natural owner
|
|
121
|
+
// since it's the consumer of those rows. `sys_webhook_delivery`
|
|
122
|
+
// (telemetry) is plugin-private. Registering them here means a
|
|
123
|
+
// stack just needs `plugins: [new WebhookOutboxPlugin(...)]`
|
|
124
|
+
// and both objects auto-appear in REST/Studio/Setup nav.
|
|
125
|
+
const manifest = ctx.getService<{ register(m: any): void }>('manifest');
|
|
126
|
+
if (manifest && typeof manifest.register === 'function') {
|
|
127
|
+
manifest.register({
|
|
128
|
+
id: 'com.objectstack.plugin-webhook-outbox.schema',
|
|
129
|
+
namespace: 'sys',
|
|
130
|
+
version: this.version,
|
|
131
|
+
type: 'plugin',
|
|
132
|
+
scope: 'system',
|
|
133
|
+
name: 'Webhook Outbox Schemas',
|
|
134
|
+
description:
|
|
135
|
+
'Registers sys_webhook (configuration) and sys_webhook_delivery (durable outbox telemetry).',
|
|
136
|
+
objects: [SysWebhook, SysWebhookDelivery],
|
|
137
|
+
});
|
|
138
|
+
} else {
|
|
139
|
+
ctx.logger.warn?.(
|
|
140
|
+
'[webhook-outbox] manifest service unavailable — sys_webhook / sys_webhook_delivery will NOT appear in REST or Studio nav. Register MetadataService before WebhookOutboxPlugin.',
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const outbox = this.resolveOutbox(ctx);
|
|
145
|
+
this.outboxInstance = outbox;
|
|
146
|
+
const nodeId =
|
|
147
|
+
this.options.nodeId ??
|
|
148
|
+
process.env.OBJECTSTACK_NODE_ID ??
|
|
149
|
+
`node-${Math.random().toString(36).slice(2, 10)}`;
|
|
150
|
+
|
|
151
|
+
const dispatcher = new WebhookDispatcher({
|
|
152
|
+
nodeId,
|
|
153
|
+
cluster,
|
|
154
|
+
outbox,
|
|
155
|
+
partitionCount: this.options.partitionCount,
|
|
156
|
+
batchSize: this.options.batchSize,
|
|
157
|
+
intervalMs: this.options.intervalMs,
|
|
158
|
+
lockTtlMs: this.options.lockTtlMs,
|
|
159
|
+
claimTtlMs: this.options.claimTtlMs,
|
|
160
|
+
fetchImpl: this.options.fetchImpl,
|
|
161
|
+
onAttempt: this.options.onAttempt,
|
|
162
|
+
rng: this.options.rng,
|
|
163
|
+
logger: ctx.logger,
|
|
164
|
+
});
|
|
165
|
+
this.dispatcher = dispatcher;
|
|
166
|
+
|
|
167
|
+
ctx.registerService('webhook.outbox', outbox);
|
|
168
|
+
ctx.registerService('webhook.dispatcher', dispatcher);
|
|
169
|
+
|
|
170
|
+
if (this.options.autoStart !== false) {
|
|
171
|
+
dispatcher.start();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Loud warning when running with the in-memory outbox in production —
|
|
175
|
+
// it loses data on restart and never shares rows across nodes. With
|
|
176
|
+
// the auto-pick logic above this only fires when no IDataEngine is
|
|
177
|
+
// available, but flag it loudly anyway.
|
|
178
|
+
const usingMemoryOutbox = outbox instanceof MemoryWebhookOutbox;
|
|
179
|
+
if (usingMemoryOutbox && process.env.NODE_ENV === 'production') {
|
|
180
|
+
ctx.logger.warn?.(
|
|
181
|
+
'[webhook-outbox] MemoryWebhookOutbox in production — webhook deliveries WILL be lost on process exit. Ensure ObjectQL is registered before WebhookOutboxPlugin so the SQL outbox can auto-select.',
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Auto-enqueue + retention need the kernel to be fully ready
|
|
186
|
+
// before ObjectQL / Realtime services are resolvable.
|
|
187
|
+
const autoEnqueueOpt = this.options.autoEnqueue ?? true;
|
|
188
|
+
const retentionOpt = this.options.retention ?? true;
|
|
189
|
+
|
|
190
|
+
const needsReadyHook = autoEnqueueOpt !== false || retentionOpt !== false;
|
|
191
|
+
if (needsReadyHook && typeof (ctx as any).hook === 'function') {
|
|
192
|
+
(ctx as any).hook('kernel:ready', async () => {
|
|
193
|
+
await this.bootAutoEnqueue(ctx, autoEnqueueOpt);
|
|
194
|
+
this.bootRetention(ctx, retentionOpt);
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Admin REST endpoint — POST /api/v1/webhooks/redeliver { deliveryId }.
|
|
199
|
+
// Wired in `kernel:ready` so the auth + http services are guaranteed
|
|
200
|
+
// resolvable. Gated on a session cookie so anonymous callers cannot
|
|
201
|
+
// replay deliveries; finer-grained RBAC (e.g. "only admins") can be
|
|
202
|
+
// layered on later — for now any signed-in user with access to the
|
|
203
|
+
// Setup app can redeliver. The action is also `disabled`-gated by
|
|
204
|
+
// status on the Studio side so the button only lights up on
|
|
205
|
+
// success / failed / dead rows.
|
|
206
|
+
if (typeof (ctx as any).hook === 'function') {
|
|
207
|
+
(ctx as any).hook('kernel:ready', () => {
|
|
208
|
+
this.registerAdminRoutes(ctx);
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
ctx.logger.info?.('[webhook-outbox] initialised', {
|
|
213
|
+
nodeId,
|
|
214
|
+
partitions: this.options.partitionCount ?? 8,
|
|
215
|
+
interval: this.options.intervalMs ?? 250,
|
|
216
|
+
autoEnqueue: autoEnqueueOpt !== false,
|
|
217
|
+
retention: retentionOpt !== false,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async dispose(): Promise<void> {
|
|
222
|
+
await this.autoEnqueuer?.stop();
|
|
223
|
+
this.retention?.stop();
|
|
224
|
+
await this.dispatcher?.stop();
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
private resolveOutbox(ctx: PluginContext): IWebhookOutbox {
|
|
228
|
+
const opt = this.options.outbox;
|
|
229
|
+
if (opt) {
|
|
230
|
+
return typeof opt === 'function'
|
|
231
|
+
? (opt as (c: PluginContext) => IWebhookOutbox)(ctx)
|
|
232
|
+
: opt;
|
|
233
|
+
}
|
|
234
|
+
// No explicit override — auto-pick the right backend for the host.
|
|
235
|
+
// SqlWebhookOutbox needs an `IDataEngine`; if one is resolvable
|
|
236
|
+
// (the usual case in CLI-served stacks), use it so durable rows
|
|
237
|
+
// in `sys_webhook_delivery` actually round-trip through the
|
|
238
|
+
// dispatcher and the redeliver REST endpoint. Memory is only a
|
|
239
|
+
// last-resort fallback for tests / edge environments without an
|
|
240
|
+
// engine.
|
|
241
|
+
const engine = this.tryGetService<IDataEngine>(ctx, ['objectql', 'data']);
|
|
242
|
+
if (engine) {
|
|
243
|
+
const partitionCount = this.options.partitionCount ?? 8;
|
|
244
|
+
const sql = new SqlWebhookOutbox(engine, { partitionCount });
|
|
245
|
+
ctx.logger.info?.(
|
|
246
|
+
'[webhook-outbox] auto-selected SqlWebhookOutbox (objectql engine detected)',
|
|
247
|
+
{ partitionCount },
|
|
248
|
+
);
|
|
249
|
+
return sql;
|
|
250
|
+
}
|
|
251
|
+
ctx.logger.warn?.(
|
|
252
|
+
'[webhook-outbox] no IDataEngine available — falling back to MemoryWebhookOutbox. Deliveries will NOT survive process restart and the redeliver REST endpoint will not see rows written through ObjectQL.',
|
|
253
|
+
);
|
|
254
|
+
return new MemoryWebhookOutbox();
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
private async bootAutoEnqueue(
|
|
258
|
+
ctx: PluginContext,
|
|
259
|
+
opt: boolean | AutoEnqueuerOptions,
|
|
260
|
+
): Promise<void> {
|
|
261
|
+
if (opt === false) return;
|
|
262
|
+
const engine = this.tryGetService<IDataEngine>(ctx, ['objectql', 'data']);
|
|
263
|
+
const realtime = this.tryGetService<IRealtimeService>(ctx, ['realtime']);
|
|
264
|
+
if (!engine || !realtime) {
|
|
265
|
+
ctx.logger.warn?.(
|
|
266
|
+
'[webhook-auto-enqueuer] disabled — ObjectQL or Realtime service not available',
|
|
267
|
+
{ hasEngine: !!engine, hasRealtime: !!realtime },
|
|
268
|
+
);
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
if (!this.outboxInstance) return;
|
|
272
|
+
|
|
273
|
+
const enqOpts = (typeof opt === 'object' ? opt : {}) as AutoEnqueuerOptions;
|
|
274
|
+
this.autoEnqueuer = new AutoEnqueuer(
|
|
275
|
+
engine,
|
|
276
|
+
realtime,
|
|
277
|
+
this.outboxInstance,
|
|
278
|
+
{ ...enqOpts, logger: ctx.logger },
|
|
279
|
+
);
|
|
280
|
+
await this.autoEnqueuer.start();
|
|
281
|
+
ctx.registerService('webhook.autoEnqueuer', this.autoEnqueuer);
|
|
282
|
+
ctx.logger.info?.('[webhook-auto-enqueuer] started');
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
private bootRetention(
|
|
286
|
+
ctx: PluginContext,
|
|
287
|
+
opt: boolean | DeliveryRetentionOptions,
|
|
288
|
+
): void {
|
|
289
|
+
if (opt === false) return;
|
|
290
|
+
// Only meaningful for SQL outbox — Memory has its own (process-lifetime) GC.
|
|
291
|
+
if (this.outboxInstance instanceof MemoryWebhookOutbox) return;
|
|
292
|
+
const engine = this.tryGetService<IDataEngine>(ctx, ['objectql', 'data']);
|
|
293
|
+
if (!engine) {
|
|
294
|
+
ctx.logger.warn?.(
|
|
295
|
+
'[webhook-retention] disabled — ObjectQL service not available',
|
|
296
|
+
);
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
const retOpts = (typeof opt === 'object' ? opt : {}) as DeliveryRetentionOptions;
|
|
300
|
+
this.retention = new DeliveryRetentionSweeper(engine, {
|
|
301
|
+
...retOpts,
|
|
302
|
+
logger: ctx.logger,
|
|
303
|
+
});
|
|
304
|
+
this.retention.start();
|
|
305
|
+
ctx.registerService('webhook.retention', this.retention);
|
|
306
|
+
ctx.logger.info?.('[webhook-retention] sweeper started');
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
private tryGetService<T>(ctx: PluginContext, names: string[]): T | undefined {
|
|
310
|
+
for (const n of names) {
|
|
311
|
+
try {
|
|
312
|
+
const svc = ctx.getService<T>(n);
|
|
313
|
+
if (svc) return svc;
|
|
314
|
+
} catch {
|
|
315
|
+
// fall through
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return undefined;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Mount POST /api/v1/webhooks/redeliver on the host Hono app, if one
|
|
323
|
+
* is available. Silently no-ops in environments without an HTTP
|
|
324
|
+
* server (MSW, edge tests, pure library use). Auth is delegated to
|
|
325
|
+
* the better-auth session cookie — every authenticated user counts.
|
|
326
|
+
*/
|
|
327
|
+
private registerAdminRoutes(ctx: PluginContext): void {
|
|
328
|
+
const http = this.tryGetService<any>(ctx, ['http-server']);
|
|
329
|
+
if (!http || typeof http.getRawApp !== 'function') {
|
|
330
|
+
ctx.logger.debug?.(
|
|
331
|
+
'[webhook-outbox] HTTP server not available; redeliver REST endpoint not mounted',
|
|
332
|
+
);
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
const rawApp = http.getRawApp();
|
|
336
|
+
const outbox = this.outboxInstance;
|
|
337
|
+
if (!rawApp || !outbox) return;
|
|
338
|
+
|
|
339
|
+
rawApp.post('/api/v1/webhooks/redeliver', async (c: any) => {
|
|
340
|
+
// Auth gate — require a signed-in session.
|
|
341
|
+
const userId = await this.resolveSessionUserId(ctx, c);
|
|
342
|
+
if (!userId) {
|
|
343
|
+
return c.json(
|
|
344
|
+
{
|
|
345
|
+
success: false,
|
|
346
|
+
error: 'unauthenticated',
|
|
347
|
+
message: 'Sign in to redeliver webhook deliveries.',
|
|
348
|
+
},
|
|
349
|
+
401,
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
let body: any;
|
|
353
|
+
try {
|
|
354
|
+
body = await c.req.json();
|
|
355
|
+
} catch {
|
|
356
|
+
return c.json(
|
|
357
|
+
{
|
|
358
|
+
success: false,
|
|
359
|
+
error: 'invalid_body',
|
|
360
|
+
message: 'Request body must be JSON.',
|
|
361
|
+
},
|
|
362
|
+
400,
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
const deliveryId =
|
|
366
|
+
typeof body?.deliveryId === 'string' ? body.deliveryId.trim() : '';
|
|
367
|
+
if (!deliveryId) {
|
|
368
|
+
return c.json(
|
|
369
|
+
{
|
|
370
|
+
success: false,
|
|
371
|
+
error: 'missing_delivery_id',
|
|
372
|
+
message: 'Body must include `deliveryId: string`.',
|
|
373
|
+
},
|
|
374
|
+
400,
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
try {
|
|
378
|
+
const row = await outbox.redeliver(deliveryId);
|
|
379
|
+
ctx.logger.info?.('[webhook-outbox] redelivered', {
|
|
380
|
+
deliveryId,
|
|
381
|
+
requestedBy: userId,
|
|
382
|
+
});
|
|
383
|
+
return c.json({ success: true, data: { id: row.id, status: row.status } });
|
|
384
|
+
} catch (err: any) {
|
|
385
|
+
const code = err?.code;
|
|
386
|
+
if (code === 'not_found') {
|
|
387
|
+
return c.json(
|
|
388
|
+
{ success: false, error: 'not_found', message: err.message },
|
|
389
|
+
404,
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
if (code === 'not_eligible') {
|
|
393
|
+
return c.json(
|
|
394
|
+
{ success: false, error: 'not_eligible', message: err.message },
|
|
395
|
+
409,
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
ctx.logger.error?.(
|
|
399
|
+
'[webhook-outbox] redeliver failed',
|
|
400
|
+
err as Error,
|
|
401
|
+
);
|
|
402
|
+
return c.json(
|
|
403
|
+
{
|
|
404
|
+
success: false,
|
|
405
|
+
error: 'internal_error',
|
|
406
|
+
message: err?.message ?? String(err),
|
|
407
|
+
},
|
|
408
|
+
500,
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
ctx.logger.info?.(
|
|
414
|
+
'[webhook-outbox] redeliver endpoint mounted at POST /api/v1/webhooks/redeliver',
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Resolve the requesting user's id from a better-auth session cookie.
|
|
420
|
+
* Returns `undefined` for anonymous callers — the caller decides
|
|
421
|
+
* whether that's a 401.
|
|
422
|
+
*/
|
|
423
|
+
private async resolveSessionUserId(
|
|
424
|
+
ctx: PluginContext,
|
|
425
|
+
c: any,
|
|
426
|
+
): Promise<string | undefined> {
|
|
427
|
+
try {
|
|
428
|
+
const authService: any = this.tryGetService<any>(ctx, ['auth']);
|
|
429
|
+
if (!authService) return undefined;
|
|
430
|
+
let api: any = authService.api;
|
|
431
|
+
if (!api && typeof authService.getApi === 'function') {
|
|
432
|
+
api = await authService.getApi();
|
|
433
|
+
}
|
|
434
|
+
if (!api?.getSession) return undefined;
|
|
435
|
+
const session = await api.getSession({ headers: c.req.raw.headers });
|
|
436
|
+
const uid = session?.user?.id;
|
|
437
|
+
return typeof uid === 'string' && uid.length > 0 ? uid : undefined;
|
|
438
|
+
} catch {
|
|
439
|
+
return undefined;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
package/tsconfig.json
CHANGED
|
@@ -1,18 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"extends": "../../../tsconfig.json",
|
|
3
3
|
"compilerOptions": {
|
|
4
|
-
"outDir": "
|
|
5
|
-
"rootDir": "
|
|
6
|
-
"types": [
|
|
7
|
-
"node"
|
|
8
|
-
]
|
|
4
|
+
"outDir": "dist",
|
|
5
|
+
"rootDir": "src",
|
|
6
|
+
"types": ["node"]
|
|
9
7
|
},
|
|
10
|
-
"include": [
|
|
11
|
-
|
|
12
|
-
],
|
|
13
|
-
"exclude": [
|
|
14
|
-
"dist",
|
|
15
|
-
"node_modules",
|
|
16
|
-
"**/*.test.ts"
|
|
17
|
-
]
|
|
8
|
+
"include": ["src"],
|
|
9
|
+
"exclude": ["node_modules", "dist"]
|
|
18
10
|
}
|
package/tsup.config.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
+
|
|
3
|
+
import { defineConfig } from 'tsup';
|
|
4
|
+
|
|
5
|
+
export default defineConfig({
|
|
6
|
+
entry: ['src/index.ts', 'src/sql-outbox.ts', 'src/schema.ts'],
|
|
7
|
+
splitting: true,
|
|
8
|
+
sourcemap: true,
|
|
9
|
+
clean: true,
|
|
10
|
+
dts: true,
|
|
11
|
+
format: ['esm', 'cjs'],
|
|
12
|
+
target: 'es2020',
|
|
13
|
+
external: ['vitest'],
|
|
14
|
+
});
|
package/dist/index.d.mts
DELETED
|
@@ -1,104 +0,0 @@
|
|
|
1
|
-
import { Plugin, PluginContext } from '@objectstack/core';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* A single webhook delivery target.
|
|
5
|
-
*/
|
|
6
|
-
interface WebhookSink {
|
|
7
|
-
/** Unique sink id used for log correlation. */
|
|
8
|
-
id: string;
|
|
9
|
-
/** Target HTTPS URL. */
|
|
10
|
-
url: string;
|
|
11
|
-
/** Optional HMAC-SHA256 secret. When set, `X-Objectstack-Signature: sha256=…` is added. */
|
|
12
|
-
secret?: string;
|
|
13
|
-
/**
|
|
14
|
-
* Restrict to specific object names (logical names, e.g. `lead`, `account`).
|
|
15
|
-
* Omit / empty → all objects.
|
|
16
|
-
*/
|
|
17
|
-
objects?: string[];
|
|
18
|
-
/**
|
|
19
|
-
* Restrict to specific event types. Omit / empty → all `data.record.*` events.
|
|
20
|
-
*/
|
|
21
|
-
eventTypes?: string[];
|
|
22
|
-
/** Extra headers to send (Authorization, Tenant, etc.). */
|
|
23
|
-
headers?: Record<string, string>;
|
|
24
|
-
/** Per-request timeout in milliseconds. Default 5000. */
|
|
25
|
-
timeoutMs?: number;
|
|
26
|
-
/** Retry attempts on transient failure. Default 3. Set 0 to disable retries. */
|
|
27
|
-
retries?: number;
|
|
28
|
-
}
|
|
29
|
-
/**
|
|
30
|
-
* Delivery attempt outcome surfaced to in-process listeners / tests.
|
|
31
|
-
*/
|
|
32
|
-
type WebhookDeliveryStatus = 'ok' | 'retrying' | 'failed';
|
|
33
|
-
interface WebhookDeliveryRecord {
|
|
34
|
-
sinkId: string;
|
|
35
|
-
url: string;
|
|
36
|
-
eventType: string;
|
|
37
|
-
object?: string;
|
|
38
|
-
status: WebhookDeliveryStatus;
|
|
39
|
-
httpStatus?: number;
|
|
40
|
-
attempt: number;
|
|
41
|
-
error?: string;
|
|
42
|
-
}
|
|
43
|
-
/**
|
|
44
|
-
* Plugin configuration.
|
|
45
|
-
*
|
|
46
|
-
* Sinks may be supplied programmatically OR via env vars when none are
|
|
47
|
-
* passed (suitable for 12-factor / Docker deployments):
|
|
48
|
-
*
|
|
49
|
-
* OBJECTSTACK_WEBHOOK_URL — single URL, or comma-separated URLs.
|
|
50
|
-
* OBJECTSTACK_WEBHOOK_SECRET — HMAC secret applied to all env-sourced URLs.
|
|
51
|
-
* OBJECTSTACK_WEBHOOK_OBJECTS — comma-separated object whitelist.
|
|
52
|
-
* OBJECTSTACK_WEBHOOK_EVENTS — comma-separated event-type whitelist
|
|
53
|
-
* (e.g. `data.record.created`).
|
|
54
|
-
*/
|
|
55
|
-
interface WebhooksPluginOptions {
|
|
56
|
-
/** Explicit sink list (takes precedence over env vars). */
|
|
57
|
-
sinks?: WebhookSink[];
|
|
58
|
-
/** Override fetch (mainly for tests). Defaults to globalThis.fetch. */
|
|
59
|
-
fetchImpl?: typeof fetch;
|
|
60
|
-
/** Hook invoked with each delivery outcome (mainly for tests / metrics). */
|
|
61
|
-
onDelivery?: (record: WebhookDeliveryRecord) => void;
|
|
62
|
-
}
|
|
63
|
-
/**
|
|
64
|
-
* WebhooksPlugin — fan out data.record.* events to external HTTP endpoints.
|
|
65
|
-
*
|
|
66
|
-
* @example
|
|
67
|
-
* ```ts
|
|
68
|
-
* kernel.use(new WebhooksPlugin({
|
|
69
|
-
* sinks: [
|
|
70
|
-
* { id: 'crm-sync', url: 'https://hooks.example.com/in',
|
|
71
|
-
* secret: process.env.HOOK_SECRET, objects: ['lead', 'account'] },
|
|
72
|
-
* ],
|
|
73
|
-
* }));
|
|
74
|
-
* ```
|
|
75
|
-
*/
|
|
76
|
-
declare class WebhooksPlugin implements Plugin {
|
|
77
|
-
name: string;
|
|
78
|
-
version: string;
|
|
79
|
-
type: string;
|
|
80
|
-
dependencies: string[];
|
|
81
|
-
private readonly options;
|
|
82
|
-
private subscriptionIds;
|
|
83
|
-
private realtime?;
|
|
84
|
-
private sinks;
|
|
85
|
-
private logger?;
|
|
86
|
-
constructor(options?: WebhooksPluginOptions);
|
|
87
|
-
init(ctx: PluginContext): Promise<void>;
|
|
88
|
-
start(ctx: PluginContext): Promise<void>;
|
|
89
|
-
stop(ctx: PluginContext): Promise<void>;
|
|
90
|
-
/**
|
|
91
|
-
* Resolve sinks from constructor options, falling back to env vars when
|
|
92
|
-
* none provided. Exposed for testing.
|
|
93
|
-
*/
|
|
94
|
-
private resolveSinks;
|
|
95
|
-
/**
|
|
96
|
-
* Dispatch a single event to a sink, with HMAC signing, timeout, and
|
|
97
|
-
* exponential-backoff retry. Failures past the retry budget are logged
|
|
98
|
-
* but never thrown — webhook delivery must never break the originating
|
|
99
|
-
* mutation.
|
|
100
|
-
*/
|
|
101
|
-
private dispatch;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
export { type WebhookDeliveryRecord, type WebhookDeliveryStatus, type WebhookSink, WebhooksPlugin, type WebhooksPluginOptions };
|