@objectstack/plugin-webhooks 5.2.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.
Files changed (43) hide show
  1. package/.turbo/turbo-build.log +28 -28
  2. package/CHANGELOG.md +12 -0
  3. package/dist/{chunk-JN76ZRWN.js → chunk-33LYZT7O.js} +21 -1
  4. package/dist/chunk-33LYZT7O.js.map +1 -0
  5. package/dist/chunk-BS2QTZH3.js +256 -0
  6. package/dist/chunk-BS2QTZH3.js.map +1 -0
  7. package/dist/chunk-FA66GQEO.cjs +256 -0
  8. package/dist/chunk-FA66GQEO.cjs.map +1 -0
  9. package/dist/{chunk-OW7ESXOK.cjs → chunk-MJZGD37S.cjs} +21 -1
  10. package/dist/chunk-MJZGD37S.cjs.map +1 -0
  11. package/dist/index.cjs +175 -14
  12. package/dist/index.cjs.map +1 -1
  13. package/dist/index.d.cts +16 -2
  14. package/dist/index.d.ts +16 -2
  15. package/dist/index.js +167 -6
  16. package/dist/index.js.map +1 -1
  17. package/dist/{outbox-bPQmKYPN.d.cts → outbox-CIn7LSyB.d.cts} +28 -1
  18. package/dist/{outbox-bPQmKYPN.d.ts → outbox-CIn7LSyB.d.ts} +28 -1
  19. package/dist/schema.cjs +2 -2
  20. package/dist/schema.d.cts +16 -1
  21. package/dist/schema.d.ts +16 -1
  22. package/dist/schema.js +1 -1
  23. package/dist/sql-outbox.cjs +4 -180
  24. package/dist/sql-outbox.cjs.map +1 -1
  25. package/dist/sql-outbox.d.cts +2 -1
  26. package/dist/sql-outbox.d.ts +2 -1
  27. package/dist/sql-outbox.js +3 -179
  28. package/dist/sql-outbox.js.map +1 -1
  29. package/package.json +5 -5
  30. package/src/index.ts +1 -0
  31. package/src/memory-outbox.test.ts +86 -0
  32. package/src/memory-outbox.ts +28 -0
  33. package/src/outbox.ts +34 -0
  34. package/src/sql-outbox.test.ts +80 -0
  35. package/src/sql-outbox.ts +61 -0
  36. package/src/sys-webhook-delivery.object.ts +22 -0
  37. package/src/webhook-outbox-plugin.ts +167 -5
  38. package/dist/chunk-JN76ZRWN.js.map +0 -1
  39. package/dist/chunk-M4M5FWIH.cjs +0 -15
  40. package/dist/chunk-M4M5FWIH.cjs.map +0 -1
  41. package/dist/chunk-NYSUNT6X.js +0 -15
  42. package/dist/chunk-NYSUNT6X.js.map +0 -1
  43. package/dist/chunk-OW7ESXOK.cjs.map +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@objectstack/plugin-webhooks",
3
- "version": "5.2.0",
3
+ "version": "6.0.0",
4
4
  "license": "Apache-2.0",
5
5
  "description": "Persistent, cluster-aware webhook dispatcher. Durable outbox + per-partition cluster.lock for exactly-once-ish delivery across nodes. See content/docs/concepts/webhook-delivery.mdx.",
6
6
  "type": "module",
@@ -24,10 +24,10 @@
24
24
  }
25
25
  },
26
26
  "dependencies": {
27
- "@objectstack/core": "5.2.0",
28
- "@objectstack/platform-objects": "5.2.0",
29
- "@objectstack/spec": "5.2.0",
30
- "@objectstack/service-cluster": "5.1.1"
27
+ "@objectstack/core": "6.0.0",
28
+ "@objectstack/platform-objects": "6.0.0",
29
+ "@objectstack/spec": "6.0.0",
30
+ "@objectstack/service-cluster": "5.1.2"
31
31
  },
32
32
  "devDependencies": {
33
33
  "@types/node": "^25.9.1",
package/src/index.ts CHANGED
@@ -53,3 +53,4 @@ export type {
53
53
  IWebhookOutbox,
54
54
  WebhookDelivery,
55
55
  } from './outbox.js';
56
+ export { RedeliverError } from './outbox.js';
@@ -0,0 +1,86 @@
1
+ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ /**
4
+ * MemoryWebhookOutbox — focused tests for behaviours not already covered
5
+ * via `dispatcher.test.ts`. Today that's just `redeliver()` — the rest of
6
+ * the contract is exercised end-to-end through the dispatcher path.
7
+ */
8
+
9
+ import { describe, expect, it } from 'vitest';
10
+ import { MemoryWebhookOutbox } from './memory-outbox.js';
11
+ import type { EnqueueInput } from './outbox.js';
12
+
13
+ function input(webhookId: string, eventId: string): EnqueueInput {
14
+ return {
15
+ webhookId,
16
+ eventId,
17
+ eventType: 'data.record.created',
18
+ url: 'https://example.test/hook',
19
+ payload: { hello: 'world' },
20
+ };
21
+ }
22
+
23
+ describe('MemoryWebhookOutbox.redeliver', () => {
24
+ it('resets a success row back to pending with attempts=0', async () => {
25
+ const outbox = new MemoryWebhookOutbox();
26
+ const id = await outbox.enqueue(input('wh-1', 'ev-1'));
27
+ await outbox.claim({ nodeId: 'A', limit: 10, claimTtlMs: 60_000 });
28
+ await outbox.ack(id, { success: true, httpStatus: 200, durationMs: 1 });
29
+
30
+ const row = await outbox.redeliver(id);
31
+ expect(row.status).toBe('pending');
32
+ expect(row.attempts).toBe(0);
33
+ expect(row.claimedBy).toBeUndefined();
34
+ expect(row.claimedAt).toBeUndefined();
35
+ expect(row.nextRetryAt).toBeUndefined();
36
+ expect(row.error).toBeUndefined();
37
+ expect(row.responseCode).toBeUndefined();
38
+ expect(row.responseBody).toBeUndefined();
39
+ expect(row.url).toBe('https://example.test/hook');
40
+ expect(row.payload).toEqual({ hello: 'world' });
41
+ });
42
+
43
+ it('resets a dead row and makes it claimable again', async () => {
44
+ const outbox = new MemoryWebhookOutbox();
45
+ const id = await outbox.enqueue(input('wh-1', 'ev-1'));
46
+ await outbox.claim({ nodeId: 'A', limit: 10, claimTtlMs: 60_000 });
47
+ await outbox.ack(id, {
48
+ success: false,
49
+ error: 'final',
50
+ dead: true,
51
+ durationMs: 5,
52
+ });
53
+
54
+ await outbox.redeliver(id);
55
+ const claimed = await outbox.claim({
56
+ nodeId: 'B',
57
+ limit: 10,
58
+ claimTtlMs: 60_000,
59
+ });
60
+ expect(claimed.map((r) => r.id)).toContain(id);
61
+ });
62
+
63
+ it('throws not_found when row does not exist', async () => {
64
+ const outbox = new MemoryWebhookOutbox();
65
+ await expect(outbox.redeliver('missing')).rejects.toMatchObject({
66
+ code: 'not_found',
67
+ });
68
+ });
69
+
70
+ it('throws not_eligible for pending rows', async () => {
71
+ const outbox = new MemoryWebhookOutbox();
72
+ const id = await outbox.enqueue(input('wh-1', 'ev-1'));
73
+ await expect(outbox.redeliver(id)).rejects.toMatchObject({
74
+ code: 'not_eligible',
75
+ });
76
+ });
77
+
78
+ it('throws not_eligible for in_flight rows', async () => {
79
+ const outbox = new MemoryWebhookOutbox();
80
+ const id = await outbox.enqueue(input('wh-1', 'ev-1'));
81
+ await outbox.claim({ nodeId: 'A', limit: 10, claimTtlMs: 60_000 });
82
+ await expect(outbox.redeliver(id)).rejects.toMatchObject({
83
+ code: 'not_eligible',
84
+ });
85
+ });
86
+ });
@@ -9,6 +9,7 @@ import type {
9
9
  IWebhookOutbox,
10
10
  WebhookDelivery,
11
11
  } from './outbox.js';
12
+ import { RedeliverError } from './outbox.js';
12
13
  import { hashPartition } from './partition.js';
13
14
 
14
15
  /**
@@ -124,4 +125,31 @@ export class MemoryWebhookOutbox implements IWebhookOutbox {
124
125
  const all = Array.from(this.rows.values()).map((r) => ({ ...r }));
125
126
  return filter?.status ? all.filter((r) => r.status === filter.status) : all;
126
127
  }
128
+
129
+ async redeliver(id: string): Promise<WebhookDelivery> {
130
+ const row = this.rows.get(id);
131
+ if (!row) {
132
+ throw new RedeliverError(
133
+ `Delivery row '${id}' not found`,
134
+ 'not_found',
135
+ );
136
+ }
137
+ if (row.status !== 'success' && row.status !== 'failed' && row.status !== 'dead') {
138
+ throw new RedeliverError(
139
+ `Delivery row '${id}' is '${row.status}', expected one of: success, failed, dead`,
140
+ 'not_eligible',
141
+ );
142
+ }
143
+ const now = Date.now();
144
+ row.status = 'pending';
145
+ row.attempts = 0;
146
+ row.claimedBy = undefined;
147
+ row.claimedAt = undefined;
148
+ row.nextRetryAt = undefined;
149
+ row.error = undefined;
150
+ row.responseCode = undefined;
151
+ row.responseBody = undefined;
152
+ row.updatedAt = now;
153
+ return { ...row };
154
+ }
127
155
  }
package/src/outbox.ts CHANGED
@@ -113,6 +113,22 @@ export interface AckFailure {
113
113
 
114
114
  export type AckResult = AckSuccess | AckFailure;
115
115
 
116
+ /**
117
+ * Error raised by `IWebhookOutbox.redeliver` when the requested row is
118
+ * either missing or in a non-terminal state. The dispatcher / admin UI
119
+ * surfaces this verbatim to the caller — never throw it for transient
120
+ * conditions (transport errors should bubble as native `Error`).
121
+ */
122
+ export class RedeliverError extends Error {
123
+ constructor(
124
+ message: string,
125
+ readonly code: 'not_found' | 'not_eligible',
126
+ ) {
127
+ super(message);
128
+ this.name = 'RedeliverError';
129
+ }
130
+ }
131
+
116
132
  /**
117
133
  * Pluggable storage backend for delivery rows. Implementations MUST make
118
134
  * `claim()` atomic across concurrent callers — that property is the
@@ -138,4 +154,22 @@ export interface IWebhookOutbox {
138
154
 
139
155
  /** Snapshot accessor for tests / admin tooling. */
140
156
  list(filter?: { status?: DeliveryStatus }): Promise<WebhookDelivery[]>;
157
+
158
+ /**
159
+ * Reset a terminal row back to `pending` so the dispatcher will pick
160
+ * it up again on its next tick.
161
+ *
162
+ * - Eligible source states: `success`, `failed`, `dead`.
163
+ * - Rejects `pending` / `in_flight` rows — replaying those would
164
+ * double-deliver because they're either already queued or actively
165
+ * being sent.
166
+ * - Resets `attempts=0` so the retry budget restarts.
167
+ * - Clears `claimed_by`, `claimed_at`, `next_retry_at`, `error`,
168
+ * `response_code`, `response_body`. URL / payload / secret are NOT
169
+ * touched — replay reproduces the original POST byte-for-byte.
170
+ *
171
+ * Throws `RedeliverError` with code `not_found` or `not_eligible`.
172
+ * Returns the post-reset row.
173
+ */
174
+ redeliver(id: string): Promise<WebhookDelivery>;
141
175
  }
@@ -407,4 +407,84 @@ describe('SqlWebhookOutbox', () => {
407
407
  const inFlight = await outbox.list({ status: 'in_flight' });
408
408
  expect(inFlight).toHaveLength(1);
409
409
  });
410
+
411
+ describe('redeliver', () => {
412
+ it('resets a success row back to pending with attempts=0', async () => {
413
+ const { outbox } = newOutbox();
414
+ const id = await outbox.enqueue(input('wh-1', 'ev-1'));
415
+ await outbox.claim({ nodeId: 'A', limit: 10, claimTtlMs: 60_000 });
416
+ await outbox.ack(id, { success: true, httpStatus: 200, durationMs: 5 });
417
+
418
+ const row = await outbox.redeliver(id);
419
+ expect(row.status).toBe('pending');
420
+ expect(row.attempts).toBe(0);
421
+ expect(row.claimedBy).toBeUndefined();
422
+ expect(row.claimedAt).toBeUndefined();
423
+ expect(row.nextRetryAt).toBeUndefined();
424
+ expect(row.error).toBeUndefined();
425
+ expect(row.responseCode).toBeUndefined();
426
+ expect(row.responseBody).toBeUndefined();
427
+ // Original immutable fields preserved
428
+ expect(row.url).toBe('https://example.test/hook');
429
+ expect(row.payload).toEqual({ hello: 'world' });
430
+ });
431
+
432
+ it('resets a dead row back to pending and clears retry backoff', async () => {
433
+ const { outbox } = newOutbox();
434
+ const id = await outbox.enqueue(input('wh-1', 'ev-1'));
435
+ await outbox.claim({ nodeId: 'A', limit: 10, claimTtlMs: 60_000 });
436
+ await outbox.ack(id, {
437
+ success: false,
438
+ error: 'final',
439
+ dead: true,
440
+ durationMs: 5,
441
+ });
442
+
443
+ const row = await outbox.redeliver(id);
444
+ expect(row.status).toBe('pending');
445
+ expect(row.attempts).toBe(0);
446
+ expect(row.error).toBeUndefined();
447
+ expect(row.nextRetryAt).toBeUndefined();
448
+ });
449
+
450
+ it('throws not_found when row does not exist', async () => {
451
+ const { outbox } = newOutbox();
452
+ await expect(outbox.redeliver('missing')).rejects.toMatchObject({
453
+ code: 'not_found',
454
+ });
455
+ });
456
+
457
+ it('throws not_eligible for pending rows', async () => {
458
+ const { outbox } = newOutbox();
459
+ const id = await outbox.enqueue(input('wh-1', 'ev-1'));
460
+ await expect(outbox.redeliver(id)).rejects.toMatchObject({
461
+ code: 'not_eligible',
462
+ });
463
+ });
464
+
465
+ it('throws not_eligible for in_flight rows', async () => {
466
+ const { outbox } = newOutbox();
467
+ const id = await outbox.enqueue(input('wh-1', 'ev-1'));
468
+ await outbox.claim({ nodeId: 'A', limit: 10, claimTtlMs: 60_000 });
469
+ await expect(outbox.redeliver(id)).rejects.toMatchObject({
470
+ code: 'not_eligible',
471
+ });
472
+ });
473
+
474
+ it('redelivered row is immediately claimable again', async () => {
475
+ const { outbox } = newOutbox();
476
+ const id = await outbox.enqueue(input('wh-1', 'ev-1'));
477
+ await outbox.claim({ nodeId: 'A', limit: 10, claimTtlMs: 60_000 });
478
+ await outbox.ack(id, { success: true, httpStatus: 200, durationMs: 1 });
479
+
480
+ await outbox.redeliver(id);
481
+
482
+ const claimed = await outbox.claim({
483
+ nodeId: 'B',
484
+ limit: 10,
485
+ claimTtlMs: 60_000,
486
+ });
487
+ expect(claimed.map((r) => r.id)).toContain(id);
488
+ });
489
+ });
410
490
  });
package/src/sql-outbox.ts CHANGED
@@ -10,6 +10,7 @@ import type {
10
10
  IWebhookOutbox,
11
11
  WebhookDelivery,
12
12
  } from './outbox.js';
13
+ import { RedeliverError } from './outbox.js';
13
14
  import { hashPartition } from './partition.js';
14
15
  import { SYS_WEBHOOK_DELIVERY } from './schema.js';
15
16
 
@@ -254,6 +255,66 @@ export class SqlWebhookOutbox implements IWebhookOutbox {
254
255
  return rows.map((r) => this.toDelivery(r));
255
256
  }
256
257
 
258
+ async redeliver(id: string): Promise<WebhookDelivery> {
259
+ const current = (await this.engine.findOne(this.objectName, {
260
+ where: { id },
261
+ })) as DeliveryRow | null;
262
+ if (!current) {
263
+ throw new RedeliverError(
264
+ `Delivery row '${id}' not found`,
265
+ 'not_found',
266
+ );
267
+ }
268
+ if (
269
+ current.status !== 'success' &&
270
+ current.status !== 'failed' &&
271
+ current.status !== 'dead'
272
+ ) {
273
+ throw new RedeliverError(
274
+ `Delivery row '${id}' is '${current.status}', expected one of: success, failed, dead`,
275
+ 'not_eligible',
276
+ );
277
+ }
278
+ const now = Date.now();
279
+ // Guarded UPDATE — re-check status server-side so two concurrent
280
+ // redeliver calls cannot both flip the row, and so a dispatcher
281
+ // tick that flipped the row to in_flight between our SELECT and
282
+ // UPDATE cannot be clobbered.
283
+ await this.engine.update(
284
+ this.objectName,
285
+ {
286
+ status: 'pending',
287
+ attempts: 0,
288
+ claimed_by: null,
289
+ claimed_at: null,
290
+ next_retry_at: null,
291
+ last_attempted_at: null,
292
+ response_code: null,
293
+ response_body: null,
294
+ error: null,
295
+ updated_at: now,
296
+ },
297
+ {
298
+ where: {
299
+ id,
300
+ status: { $in: ['success', 'failed', 'dead'] },
301
+ },
302
+ multi: false,
303
+ },
304
+ );
305
+ const after = (await this.engine.findOne(this.objectName, {
306
+ where: { id },
307
+ })) as DeliveryRow | null;
308
+ if (!after || after.status !== 'pending') {
309
+ // Lost the race — another writer flipped the row.
310
+ throw new RedeliverError(
311
+ `Delivery row '${id}' state changed during redeliver`,
312
+ 'not_eligible',
313
+ );
314
+ }
315
+ return this.toDelivery(after);
316
+ }
317
+
257
318
  private toDelivery(r: DeliveryRow): WebhookDelivery {
258
319
  return {
259
320
  id: r.id,
@@ -42,6 +42,28 @@ export const SysWebhookDelivery = ObjectSchema.create({
42
42
  titleFormat: '{event_type} → {url}',
43
43
  compactLayout: ['event_type', 'url', 'status', 'attempts', 'next_retry_at'],
44
44
 
45
+ actions: [
46
+ {
47
+ name: 'redeliver',
48
+ label: 'Redeliver',
49
+ icon: 'refresh-cw',
50
+ variant: 'secondary',
51
+ locations: ['list_item', 'record_header'],
52
+ type: 'api',
53
+ target: '/api/v1/webhooks/redeliver',
54
+ method: 'POST',
55
+ recordIdParam: 'deliveryId',
56
+ confirmText:
57
+ 'Replay this delivery? The receiver will get the original payload again — they must be idempotent on the X-Objectstack-Delivery header.',
58
+ successMessage: 'Queued for redelivery',
59
+ refreshAfter: true,
60
+ // Only terminal rows are safe to replay. Pending / in_flight rows
61
+ // are either already queued or actively being sent — replaying
62
+ // would double-deliver.
63
+ disabled: "!(status in ['success', 'failed', 'dead'])",
64
+ },
65
+ ],
66
+
45
67
  listViews: {
46
68
  recent: {
47
69
  type: 'grid',
@@ -15,6 +15,7 @@ import {
15
15
  DeliveryRetentionSweeper,
16
16
  type DeliveryRetentionOptions,
17
17
  } from './retention.js';
18
+ import { SqlWebhookOutbox } from './sql-outbox.js';
18
19
  import { SysWebhookDelivery } from './sys-webhook-delivery.object.js';
19
20
 
20
21
  export interface WebhookOutboxPluginOptions
@@ -171,11 +172,13 @@ export class WebhookOutboxPlugin implements Plugin {
171
172
  }
172
173
 
173
174
  // Loud warning when running with the in-memory outbox in production —
174
- // it loses data on restart and never shares rows across nodes.
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.
175
178
  const usingMemoryOutbox = outbox instanceof MemoryWebhookOutbox;
176
179
  if (usingMemoryOutbox && process.env.NODE_ENV === 'production') {
177
180
  ctx.logger.warn?.(
178
- '[webhook-outbox] MemoryWebhookOutbox in production — webhook deliveries WILL be lost on process exit. Pass `outbox: (ctx) => new SqlWebhookOutbox(ctx.getService("objectql"), { partitionCount: 8 })` from `@objectstack/plugin-webhooks/sql`.',
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.',
179
182
  );
180
183
  }
181
184
 
@@ -192,6 +195,20 @@ export class WebhookOutboxPlugin implements Plugin {
192
195
  });
193
196
  }
194
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
+
195
212
  ctx.logger.info?.('[webhook-outbox] initialised', {
196
213
  nodeId,
197
214
  partitions: this.options.partitionCount ?? 8,
@@ -209,9 +226,32 @@ export class WebhookOutboxPlugin implements Plugin {
209
226
 
210
227
  private resolveOutbox(ctx: PluginContext): IWebhookOutbox {
211
228
  const opt = this.options.outbox;
212
- if (!opt) return new MemoryWebhookOutbox();
213
- if (typeof opt === 'function') return (opt as (c: PluginContext) => IWebhookOutbox)(ctx);
214
- return opt;
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();
215
255
  }
216
256
 
217
257
  private async bootAutoEnqueue(
@@ -277,4 +317,126 @@ export class WebhookOutboxPlugin implements Plugin {
277
317
  }
278
318
  return undefined;
279
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
+ }
280
442
  }
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/sys-webhook-delivery.object.ts"],"sourcesContent":["// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.\n\nimport { Field, ObjectSchema } from '@objectstack/spec/data';\n\n/**\n * sys_webhook_delivery — Durable outbox row for one HTTP attempt.\n *\n * Schema is owned by `@objectstack/plugin-webhooks`. Add it to your stack\n * via:\n *\n * import { SysWebhookDelivery } from '@objectstack/plugin-webhooks/schema';\n * defineStack({ objects: [SysWebhookDelivery, ...], plugins: [...] });\n *\n * Designed for the SqlWebhookOutbox claim algorithm:\n *\n * 1. Producers INSERT pending rows (dedup'd by (event_id, webhook_id)).\n * 2. The dispatcher's per-partition lock-holder runs:\n * SELECT id WHERE status='pending' AND partition_key=? AND (next_retry_at <= now OR null)\n * UPDATE SET status='in_flight' WHERE id IN (...) AND status='pending' ← atomic claim\n * POST to target URL\n * UPDATE SET status=success/pending/dead, attempts=attempts+1, ...\n *\n * `partition_key` is precomputed on enqueue (hash(webhook_id) mod N) so the\n * dispatcher can filter cheaply without DB-side hash functions.\n *\n * Indexes are tuned for the hot path: `(status, partition_key, next_retry_at)`\n * is the claim query; `(event_id, webhook_id)` is the dedup uniqueness.\n *\n * @namespace sys\n */\nexport const SysWebhookDelivery = ObjectSchema.create({\n name: 'sys_webhook_delivery',\n label: 'Webhook Delivery',\n pluralLabel: 'Webhook Deliveries',\n icon: 'package',\n isSystem: true,\n managedBy: 'config',\n userActions: { create: false, edit: false, delete: false, import: false },\n description:\n 'Durable outbox row for one webhook attempt. Managed by @objectstack/plugin-webhooks; do not write directly.',\n displayNameField: 'id',\n titleFormat: '{event_type} → {url}',\n compactLayout: ['event_type', 'url', 'status', 'attempts', 'next_retry_at'],\n\n listViews: {\n recent: {\n type: 'grid',\n name: 'recent',\n label: 'Recent',\n data: { provider: 'object', object: 'sys_webhook_delivery' },\n columns: ['event_type', 'url', 'status', 'attempts', 'response_code', 'updated_at'],\n sort: [{ field: 'updated_at', order: 'desc' }],\n pagination: { pageSize: 50 },\n },\n failures: {\n type: 'grid',\n name: 'failures',\n label: 'Failures',\n data: { provider: 'object', object: 'sys_webhook_delivery' },\n columns: ['event_type', 'url', 'status', 'attempts', 'response_code', 'error', 'updated_at'],\n filter: [{ field: 'status', operator: 'in', value: ['failed', 'dead'] }],\n sort: [{ field: 'updated_at', order: 'desc' }],\n pagination: { pageSize: 50 },\n },\n in_flight: {\n type: 'grid',\n name: 'in_flight',\n label: 'In Flight',\n data: { provider: 'object', object: 'sys_webhook_delivery' },\n columns: ['event_type', 'url', 'attempts', 'claimed_by', 'claimed_at'],\n filter: [{ field: 'status', operator: 'equals', value: 'in_flight' }],\n sort: [{ field: 'claimed_at', order: 'desc' }],\n pagination: { pageSize: 50 },\n },\n pending: {\n type: 'grid',\n name: 'pending',\n label: 'Pending',\n data: { provider: 'object', object: 'sys_webhook_delivery' },\n columns: ['event_type', 'url', 'attempts', 'next_retry_at', 'updated_at'],\n filter: [{ field: 'status', operator: 'equals', value: 'pending' }],\n sort: [{ field: 'next_retry_at', order: 'asc' }],\n pagination: { pageSize: 50 },\n },\n by_status: {\n type: 'grid',\n name: 'by_status',\n label: 'By Status',\n data: { provider: 'object', object: 'sys_webhook_delivery' },\n columns: ['status', 'event_type', 'url', 'attempts', 'updated_at'],\n sort: [{ field: 'status', order: 'asc' }, { field: 'updated_at', order: 'desc' }],\n grouping: { fields: [{ field: 'status', order: 'asc', collapsed: false }] },\n pagination: { pageSize: 100 },\n },\n by_webhook: {\n type: 'grid',\n name: 'by_webhook',\n label: 'By Webhook',\n data: { provider: 'object', object: 'sys_webhook_delivery' },\n columns: ['webhook_id', 'event_type', 'status', 'attempts', 'updated_at'],\n sort: [{ field: 'webhook_id', order: 'asc' }, { field: 'updated_at', order: 'desc' }],\n grouping: { fields: [{ field: 'webhook_id', order: 'asc', collapsed: true }] },\n pagination: { pageSize: 100 },\n },\n all_deliveries: {\n type: 'grid',\n name: 'all_deliveries',\n label: 'All',\n data: { provider: 'object', object: 'sys_webhook_delivery' },\n columns: ['event_type', 'url', 'status', 'attempts', 'response_code', 'updated_at'],\n sort: [{ field: 'updated_at', order: 'desc' }],\n pagination: { pageSize: 100 },\n },\n },\n\n fields: {\n id: Field.text({\n label: 'Delivery ID',\n required: true,\n maxLength: 64,\n description: 'UUID — also doubles as the receiver-side idempotency key',\n }),\n\n webhook_id: Field.text({\n label: 'Webhook ID',\n required: true,\n maxLength: 64,\n description: 'FK to sys_webhook.id (loosely coupled — denormalised URL/secret on row)',\n }),\n\n event_id: Field.text({\n label: 'Event ID',\n required: true,\n maxLength: 128,\n description: 'Source event id; UNIQUE(event_id, webhook_id) for dedup',\n }),\n\n event_type: Field.text({\n label: 'Event Type',\n required: true,\n maxLength: 128,\n description: 'e.g. data.record.created',\n }),\n\n url: Field.text({\n label: 'Target URL',\n required: true,\n maxLength: 2048,\n description: 'Snapshotted at enqueue so config edits do not rewrite live rows',\n }),\n\n method: Field.text({ label: 'Method', required: false, maxLength: 10 }),\n headers_json: Field.textarea({ label: 'Headers JSON', required: false }),\n secret: Field.text({ label: 'HMAC Secret', required: false, maxLength: 256 }),\n timeout_ms: Field.number({ label: 'Timeout (ms)', required: false }),\n payload_json: Field.textarea({ label: 'Payload JSON', required: true }),\n\n partition_key: Field.number({\n label: 'Partition',\n required: true,\n description: 'hash(webhook_id) mod partitionCount — precomputed for cheap WHERE',\n }),\n\n status: Field.text({\n label: 'Status',\n required: true,\n defaultValue: 'pending',\n maxLength: 16,\n description: 'pending | in_flight | success | failed | dead',\n }),\n\n attempts: Field.number({\n label: 'Attempts',\n required: true,\n defaultValue: 0,\n description: 'Number of POST attempts made so far',\n }),\n\n claimed_by: Field.text({ label: 'Claimed By', required: false, maxLength: 128 }),\n claimed_at: Field.number({ label: 'Claimed At (ms)', required: false }),\n next_retry_at: Field.number({ label: 'Next Retry At (ms)', required: false }),\n last_attempted_at: Field.number({ label: 'Last Attempted At (ms)', required: false }),\n response_code: Field.number({ label: 'HTTP Status', required: false }),\n response_body: Field.textarea({ label: 'Response Body (capped)', required: false }),\n error: Field.textarea({ label: 'Error', required: false }),\n\n created_at: Field.number({ label: 'Created At (ms)', required: true }),\n updated_at: Field.number({ label: 'Updated At (ms)', required: true }),\n },\n\n indexes: [\n { fields: ['event_id', 'webhook_id'], unique: true },\n // Hot path: claim query\n { fields: ['status', 'partition_key', 'next_retry_at'] },\n // Reaper: scan stale in_flight rows by claimed_at\n { fields: ['status', 'claimed_at'] },\n { fields: ['webhook_id'] },\n ],\n});\n\n/** Canonical object name — exported so SqlWebhookOutbox callers can override if needed. */\nexport const SYS_WEBHOOK_DELIVERY = 'sys_webhook_delivery' as const;\n"],"mappings":"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","names":[]}
@@ -1,15 +0,0 @@
1
- "use strict";Object.defineProperty(exports, "__esModule", {value: true});// src/partition.ts
2
- function hashPartition(key, count) {
3
- if (count <= 0) throw new Error("partition count must be > 0");
4
- let h = 2166136261;
5
- for (let i = 0; i < key.length; i++) {
6
- h ^= key.charCodeAt(i);
7
- h = Math.imul(h, 16777619);
8
- }
9
- return Math.abs(h | 0) % count;
10
- }
11
-
12
-
13
-
14
- exports.hashPartition = hashPartition;
15
- //# sourceMappingURL=chunk-M4M5FWIH.cjs.map
@@ -1 +0,0 @@
1
- {"version":3,"sources":["/home/runner/work/framework/framework/packages/plugins/plugin-webhooks/dist/chunk-M4M5FWIH.cjs","../src/partition.ts"],"names":[],"mappings":"AAAA;ACUO,SAAS,aAAA,CAAc,GAAA,EAAa,KAAA,EAAuB;AAC9D,EAAA,GAAA,CAAI,MAAA,GAAS,CAAA,EAAG,MAAM,IAAI,KAAA,CAAM,6BAA6B,CAAA;AAC7D,EAAA,IAAI,EAAA,EAAI,UAAA;AACR,EAAA,IAAA,CAAA,IAAS,EAAA,EAAI,CAAA,EAAG,EAAA,EAAI,GAAA,CAAI,MAAA,EAAQ,CAAA,EAAA,EAAK;AACjC,IAAA,EAAA,GAAK,GAAA,CAAI,UAAA,CAAW,CAAC,CAAA;AACrB,IAAA,EAAA,EAAI,IAAA,CAAK,IAAA,CAAK,CAAA,EAAG,QAAU,CAAA;AAAA,EAC/B;AACA,EAAA,OAAO,IAAA,CAAK,GAAA,CAAI,EAAA,EAAI,CAAC,EAAA,EAAI,KAAA;AAC7B;ADRA;AACA;AACE;AACF,sCAAC","file":"/home/runner/work/framework/framework/packages/plugins/plugin-webhooks/dist/chunk-M4M5FWIH.cjs","sourcesContent":[null,"// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.\n\n/**\n * Stable, framework-free partition hash. The dispatcher uses this to\n * assign webhooks to partitions; the in-memory outbox uses the same hash\n * to filter rows in `claim()`. Both call sites MUST agree, which is why\n * this is a single shared helper.\n *\n * Uses a 32-bit FNV-1a variant — fast, no allocations, deterministic.\n */\nexport function hashPartition(key: string, count: number): number {\n if (count <= 0) throw new Error('partition count must be > 0');\n let h = 0x811c9dc5;\n for (let i = 0; i < key.length; i++) {\n h ^= key.charCodeAt(i);\n h = Math.imul(h, 0x01000193);\n }\n return Math.abs(h | 0) % count;\n}\n"]}