webspresso 0.0.99 → 0.0.101

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 (108) hide show
  1. package/.agents/ADMIN.md +47 -4
  2. package/.agents/AGENTS.md +4 -2
  3. package/.agents/AUTH.md +309 -0
  4. package/.agents/CLI.md +128 -0
  5. package/.agents/EMAIL.md +84 -2
  6. package/.agents/ERRORS.md +59 -0
  7. package/.agents/EVENTS.md +179 -0
  8. package/.agents/ORM.md +149 -0
  9. package/.agents/PLUGINS.md +44 -7
  10. package/.agents/QUEUE.md +18 -10
  11. package/.agents/ROUTING.md +97 -1
  12. package/.agents/STREAMING.md +35 -0
  13. package/AGENTS.md +2 -0
  14. package/README.md +145 -93
  15. package/bin/commands/add-docker.js +467 -0
  16. package/bin/commands/db-makemigrations.js +83 -0
  17. package/bin/commands/deploy-check.js +339 -0
  18. package/bin/commands/list.js +96 -0
  19. package/bin/commands/make-command.js +93 -0
  20. package/bin/commands/make-event.js +80 -0
  21. package/bin/commands/make-factory.js +72 -0
  22. package/bin/commands/make-mail.js +93 -0
  23. package/bin/commands/make-policy.js +106 -0
  24. package/bin/commands/run.js +195 -0
  25. package/bin/webspresso.js +21 -0
  26. package/core/auth/bouncer.js +196 -0
  27. package/core/auth/index.js +73 -1
  28. package/core/auth/magic-link.js +170 -0
  29. package/core/auth/manager.js +343 -3
  30. package/core/auth/middleware.js +45 -6
  31. package/core/auth/session-manager.js +255 -0
  32. package/core/auth/sessions.js +351 -0
  33. package/core/auth/tokens.js +1 -0
  34. package/core/auth/totp.js +4 -1
  35. package/core/auth/user-agent.js +161 -0
  36. package/core/commands/define-command.js +48 -0
  37. package/core/commands/index.js +18 -0
  38. package/core/commands/registry.js +156 -0
  39. package/core/commands/runner.js +76 -0
  40. package/core/compression/compressible.js +1 -1
  41. package/core/compression/middleware.js +35 -251
  42. package/core/content/service.js +8 -2
  43. package/core/events/discovery.js +142 -0
  44. package/core/events/dispatcher.js +552 -0
  45. package/core/events/index.js +23 -0
  46. package/core/events/signal.js +92 -0
  47. package/core/forms/flash.js +234 -0
  48. package/core/forms/form-builder.js +214 -0
  49. package/core/forms/index.js +16 -0
  50. package/core/mail/index.js +23 -0
  51. package/core/mail/mailable.js +250 -0
  52. package/core/mail/mailer.js +184 -0
  53. package/core/mail/transports.js +238 -0
  54. package/core/orm/factory.js +250 -0
  55. package/core/orm/index.js +8 -1
  56. package/core/orm/migrations/diff.js +185 -0
  57. package/core/orm/migrations/index.js +22 -4
  58. package/core/orm/model.js +55 -1
  59. package/core/orm/repository.js +283 -13
  60. package/core/orm/scopes.js +3 -2
  61. package/core/orm/types.js +6 -1
  62. package/core/queue/cron.js +252 -39
  63. package/core/queue/manager.js +20 -2
  64. package/core/queue/scheduler.js +115 -59
  65. package/core/ssr/stream.js +62 -2
  66. package/index.d.ts +540 -14
  67. package/index.js +44 -0
  68. package/package.json +11 -5
  69. package/plugins/admin-panel/api.js +509 -3
  70. package/plugins/admin-panel/index.js +17 -0
  71. package/plugins/admin-panel/modules/admin-users.js +1 -1
  72. package/plugins/audit-log/admin-component.js +54 -54
  73. package/plugins/audit-log/index.js +2 -2
  74. package/plugins/content/client/inline-edit.js +2 -46
  75. package/plugins/content/models/content-entry.js +4 -3
  76. package/plugins/content/models/content-type.js +4 -3
  77. package/plugins/cors.js +15 -4
  78. package/plugins/email/auth-bridge.js +66 -0
  79. package/plugins/email/bundled-templates.js +15 -0
  80. package/plugins/email/index.js +48 -0
  81. package/plugins/email/preview-handler.js +555 -0
  82. package/plugins/email/service.js +23 -12
  83. package/plugins/email/templates/auth-magic-link.mjml +13 -0
  84. package/plugins/email/transport.js +30 -11
  85. package/plugins/file-manager/admin-component.js +49 -49
  86. package/plugins/file-manager/index.js +4 -4
  87. package/plugins/index.js +2 -0
  88. package/plugins/redirect/index.js +1 -1
  89. package/plugins/webhooks/admin-component.js +608 -0
  90. package/plugins/webhooks/api-handlers.js +237 -0
  91. package/plugins/webhooks/crypto.js +144 -0
  92. package/plugins/webhooks/dispatcher.js +708 -0
  93. package/plugins/webhooks/index.js +263 -0
  94. package/plugins/webhooks/matcher.js +113 -0
  95. package/plugins/webhooks/migration-template.js +75 -0
  96. package/plugins/webhooks/models.js +98 -0
  97. package/plugins/webhooks/sender.js +176 -0
  98. package/src/api/api-loader.js +1 -1
  99. package/src/app-context.js +48 -6
  100. package/src/bundle/index.js +2 -1
  101. package/src/dev-reload/index.js +9 -1
  102. package/src/discovery/scan-routes.js +10 -1
  103. package/src/file-router.js +173 -4
  104. package/src/helpers.js +150 -1
  105. package/src/njk-frontmatter.js +3 -0
  106. package/src/pages/page-loader.js +1 -1
  107. package/src/routing/cascading.js +120 -4
  108. package/src/server.js +180 -26
@@ -0,0 +1,263 @@
1
+ /**
2
+ * Webspresso Webhooks Plugin
3
+ * Outgoing Webhook Dispatcher, Signature Verification, Event Matching & Admin Inspector
4
+ * @module plugins/webhooks
5
+ */
6
+
7
+ 'use strict';
8
+
9
+ const { generateSecret, safeCompare, signPayload, verifySignature } = require('./crypto');
10
+ const { matchEvent, matchesAnyEvent, normalizeEvents } = require('./matcher');
11
+ const { sendWebhook } = require('./sender');
12
+ const { createWebhookEndpointModel, createWebhookDeliveryModel } = require('./models');
13
+ const { generateWebhooksMigration } = require('./migration-template');
14
+ const { WebhookDispatcher, generateDeliveryId } = require('./dispatcher');
15
+ const { createWebhookApiHandlers } = require('./api-handlers');
16
+ const { generateWebhooksComponent } = require('./admin-component');
17
+
18
+ /**
19
+ * Webhooks Plugin Factory
20
+ *
21
+ * @param {Object} [options]
22
+ * @param {any} [options.db] - Webspresso database or Knex instance
23
+ * @param {any} [options.knex] - Knex instance
24
+ * @param {string} [options.endpointsTable='webhook_endpoints']
25
+ * @param {string} [options.deliveriesTable='webhook_deliveries']
26
+ * @param {number} [options.maxAttempts=3] - Maximum retry attempts
27
+ * @param {number} [options.backoffBaseMs=1000] - Base delay for exponential backoff (ms)
28
+ * @param {number} [options.timeoutMs=10000] - Request timeout (ms)
29
+ * @param {boolean} [options.wrapPayload=true] - Wrap data in standard event envelope
30
+ * @param {any} [options.queue] - Webspresso QueueManager instance
31
+ * @param {Array<Object>} [options.endpoints] - Static in-memory endpoints
32
+ * @param {string} [options.apiPrefix='/webhooks'] - Admin API prefix
33
+ * @param {boolean} [options.includeDefaultPage=true] - Register Admin Panel page & menu
34
+ * @param {string} [options.pageTitle='Webhooks'] - Admin page title
35
+ * @param {number} [options.menuOrder=92] - Admin sidebar menu order
36
+ * @returns {Object} Webspresso Plugin Definition
37
+ */
38
+ function webhooksPlugin(options = {}) {
39
+ const {
40
+ endpointsTable = 'webhook_endpoints',
41
+ deliveriesTable = 'webhook_deliveries',
42
+ apiPrefix = '/webhooks',
43
+ includeDefaultPage = true,
44
+ pageTitle = 'Webhooks',
45
+ menuOrder = 92,
46
+ } = options;
47
+
48
+ const dispatcher = new WebhookDispatcher({
49
+ ...options,
50
+ endpointsTable,
51
+ deliveriesTable,
52
+ });
53
+
54
+ const apiHandlers = createWebhookApiHandlers(dispatcher, options);
55
+
56
+ return {
57
+ name: 'webhooks',
58
+ version: '1.0.0',
59
+ description: 'Outgoing Webhook Dispatcher with HMAC-SHA256 signatures, wildcard matching, retries, and Admin UI',
60
+ options,
61
+
62
+ api: {
63
+ dispatcher,
64
+ dispatch: (event, payload, opts) => dispatcher.dispatch(event, payload, opts),
65
+ pingEndpoint: (id) => dispatcher.pingEndpoint(id),
66
+ retryDelivery: (id) => dispatcher.retryDelivery(id),
67
+ createEndpoint: (data) => dispatcher.createEndpoint(data),
68
+ getEndpoint: (id) => dispatcher.getEndpoint(id),
69
+ updateEndpoint: (id, data) => dispatcher.updateEndpoint(id, data),
70
+ deleteEndpoint: (id) => dispatcher.deleteEndpoint(id),
71
+ listEndpoints: (filters) => dispatcher.listEndpoints(filters),
72
+ listDeliveries: (filters) => dispatcher.listDeliveries(filters),
73
+ getDelivery: (id) => dispatcher.getDelivery(id),
74
+ purgeDeliveries: (opts) => dispatcher.purgeDeliveries(opts),
75
+ generateSecret,
76
+ safeCompare,
77
+ signPayload,
78
+ verifySignature,
79
+ matchEvent,
80
+ matchesAnyEvent,
81
+ sendWebhook,
82
+ getMigrationTemplate: (epTable, dlvTable) =>
83
+ generateWebhooksMigration(epTable || endpointsTable, dlvTable || deliveriesTable),
84
+ getModels: () => ({
85
+ WebhookEndpoint: createWebhookEndpointModel(endpointsTable),
86
+ WebhookDelivery: createWebhookDeliveryModel(deliveriesTable),
87
+ }),
88
+ },
89
+
90
+ register(ctx) {
91
+ const db = options.db || (ctx && ctx.db);
92
+ if (db && typeof db.registerModel === 'function' && typeof db.hasModel === 'function') {
93
+ if (!db.hasModel('WebhookEndpoint')) {
94
+ db.registerModel(createWebhookEndpointModel(endpointsTable));
95
+ }
96
+ if (!db.hasModel('WebhookDelivery')) {
97
+ db.registerModel(createWebhookDeliveryModel(deliveriesTable));
98
+ }
99
+ }
100
+
101
+ // If DB was not passed in plugin options, bind it from ctx
102
+ if (!dispatcher.db && db) {
103
+ dispatcher.db = db;
104
+ dispatcher.knex = db.knex || db;
105
+ }
106
+
107
+ // Attach dispatcher to app and request objects
108
+ if (ctx.app) {
109
+ ctx.app.webhooks = dispatcher;
110
+ ctx.app.use((req, res, next) => {
111
+ req.webhooks = dispatcher;
112
+ next();
113
+ });
114
+
115
+ // Register built-in services in app.serviceRegistry if available
116
+ if (ctx.app.serviceRegistry && typeof ctx.app.serviceRegistry.register === 'function') {
117
+ if (!ctx.app.serviceRegistry.has('webhook.dispatch')) {
118
+ ctx.app.serviceRegistry.register('webhook.dispatch', {
119
+ handler: async (input) => dispatcher.dispatch(input.event, input.payload, input),
120
+ });
121
+ }
122
+ if (!ctx.app.serviceRegistry.has('webhook.ping')) {
123
+ ctx.app.serviceRegistry.register('webhook.ping', {
124
+ handler: async (input) => dispatcher.pingEndpoint(input.endpointId),
125
+ });
126
+ }
127
+ if (!ctx.app.serviceRegistry.has('webhook.retry')) {
128
+ ctx.app.serviceRegistry.register('webhook.retry', {
129
+ handler: async (input) => dispatcher.retryDelivery(input.deliveryId),
130
+ });
131
+ }
132
+ }
133
+
134
+ // Integrate with core/queue if queue is present
135
+ const queueManager = options.queue || ctx.app.queue;
136
+ if (queueManager && typeof queueManager.define === 'function') {
137
+ dispatcher.setQueue(queueManager);
138
+ if (!queueManager.handlers?.has?.('webhooks.deliver')) {
139
+ queueManager.define('webhooks.deliver', async (job) => {
140
+ const { endpointId, deliveryId, event, payload, attempt } = job.data;
141
+ const endpoint = await dispatcher.getEndpoint(endpointId);
142
+ if (!endpoint) {
143
+ throw new Error(`Endpoint #${endpointId} no longer exists.`);
144
+ }
145
+ const outcome = await dispatcher.executeDelivery({
146
+ endpoint,
147
+ deliveryId,
148
+ event,
149
+ payload,
150
+ attempt: attempt || 1,
151
+ autoRetry: false, // Let queue handle retries
152
+ });
153
+ if (!outcome.success) {
154
+ throw new Error(outcome.error || `Delivery failed with status ${outcome.statusCode}`);
155
+ }
156
+ return outcome;
157
+ }, {
158
+ attempts: options.maxAttempts || 3,
159
+ backoff: { type: 'exponential', delay: options.backoffBaseMs || 1000 },
160
+ timeout: options.timeoutMs || 10000,
161
+ });
162
+ }
163
+ }
164
+ }
165
+
166
+ return () => {
167
+ dispatcher.dispose();
168
+ };
169
+ },
170
+
171
+ dispose() {
172
+ dispatcher.dispose();
173
+ },
174
+
175
+ shutdown() {
176
+ dispatcher.dispose();
177
+ },
178
+
179
+ onRoutesReady(ctx) {
180
+ const adminApi = ctx.usePlugin('admin-panel');
181
+ if (!adminApi) {
182
+ return;
183
+ }
184
+
185
+ adminApi.registerModule({
186
+ id: 'webhooks',
187
+ title: pageTitle,
188
+
189
+ pages: includeDefaultPage
190
+ ? [{
191
+ id: 'webhooks',
192
+ title: pageTitle,
193
+ path: '/webhooks',
194
+ icon: 'send',
195
+ component: generateWebhooksComponent({ apiPrefix }),
196
+ }]
197
+ : [],
198
+
199
+ menu: includeDefaultPage
200
+ ? [{
201
+ id: 'webhooks',
202
+ label: pageTitle,
203
+ path: '/webhooks',
204
+ icon: 'send',
205
+ order: menuOrder,
206
+ }]
207
+ : [],
208
+
209
+ api: {
210
+ prefix: apiPrefix,
211
+ routes: [
212
+ { method: 'get', path: '/endpoints', handler: apiHandlers.listEndpointsHandler },
213
+ { method: 'post', path: '/endpoints', handler: apiHandlers.createEndpointHandler },
214
+ { method: 'get', path: '/endpoints/:id', handler: apiHandlers.getEndpointHandler },
215
+ { method: 'put', path: '/endpoints/:id', handler: apiHandlers.updateEndpointHandler },
216
+ { method: 'patch', path: '/endpoints/:id', handler: apiHandlers.updateEndpointHandler },
217
+ { method: 'delete', path: '/endpoints/:id', handler: apiHandlers.deleteEndpointHandler },
218
+ { method: 'post', path: '/endpoints/:id/ping', handler: apiHandlers.pingEndpointHandler },
219
+ { method: 'get', path: '/deliveries', handler: apiHandlers.listDeliveriesHandler },
220
+ { method: 'get', path: '/deliveries/:id', handler: apiHandlers.getDeliveryHandler },
221
+ { method: 'post', path: '/deliveries/:id/retry', handler: apiHandlers.retryDeliveryHandler },
222
+ { method: 'post', path: '/deliveries/purge', handler: apiHandlers.purgeDeliveriesHandler },
223
+ ],
224
+ },
225
+ });
226
+ },
227
+ };
228
+ }
229
+
230
+ webhooksPlugin.webhooksPlugin = webhooksPlugin;
231
+ webhooksPlugin.WebhookDispatcher = WebhookDispatcher;
232
+ webhooksPlugin.generateSecret = generateSecret;
233
+ webhooksPlugin.safeCompare = safeCompare;
234
+ webhooksPlugin.signPayload = signPayload;
235
+ webhooksPlugin.verifySignature = verifySignature;
236
+ webhooksPlugin.matchEvent = matchEvent;
237
+ webhooksPlugin.matchesAnyEvent = matchesAnyEvent;
238
+ webhooksPlugin.normalizeEvents = normalizeEvents;
239
+ webhooksPlugin.sendWebhook = sendWebhook;
240
+ webhooksPlugin.generateDeliveryId = generateDeliveryId;
241
+ webhooksPlugin.generateWebhooksMigration = generateWebhooksMigration;
242
+ webhooksPlugin.createWebhookEndpointModel = createWebhookEndpointModel;
243
+ webhooksPlugin.createWebhookDeliveryModel = createWebhookDeliveryModel;
244
+ webhooksPlugin.createWebhookApiHandlers = createWebhookApiHandlers;
245
+ webhooksPlugin.generateWebhooksComponent = generateWebhooksComponent;
246
+
247
+ module.exports = webhooksPlugin;
248
+ module.exports.webhooksPlugin = webhooksPlugin;
249
+ module.exports.WebhookDispatcher = WebhookDispatcher;
250
+ module.exports.generateSecret = generateSecret;
251
+ module.exports.safeCompare = safeCompare;
252
+ module.exports.signPayload = signPayload;
253
+ module.exports.verifySignature = verifySignature;
254
+ module.exports.matchEvent = matchEvent;
255
+ module.exports.matchesAnyEvent = matchesAnyEvent;
256
+ module.exports.normalizeEvents = normalizeEvents;
257
+ module.exports.sendWebhook = sendWebhook;
258
+ module.exports.generateDeliveryId = generateDeliveryId;
259
+ module.exports.generateWebhooksMigration = generateWebhooksMigration;
260
+ module.exports.createWebhookEndpointModel = createWebhookEndpointModel;
261
+ module.exports.createWebhookDeliveryModel = createWebhookDeliveryModel;
262
+ module.exports.createWebhookApiHandlers = createWebhookApiHandlers;
263
+ module.exports.generateWebhooksComponent = generateWebhooksComponent;
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Webhooks Event Pattern Matcher
3
+ * Supports wildcard matching (*, user.*, order.item.*)
4
+ * @module plugins/webhooks/matcher
5
+ */
6
+
7
+ 'use strict';
8
+
9
+ /**
10
+ * Normalize an event filter into an array of strings
11
+ *
12
+ * @param {string|string[]|any} events
13
+ * @returns {string[]}
14
+ */
15
+ function normalizeEvents(events) {
16
+ if (!events) {
17
+ return ['*'];
18
+ }
19
+
20
+ if (Array.isArray(events)) {
21
+ return events
22
+ .map((e) => (typeof e === 'string' ? e.trim() : ''))
23
+ .filter(Boolean);
24
+ }
25
+
26
+ if (typeof events === 'string') {
27
+ const trimmed = events.trim();
28
+ if (!trimmed) return ['*'];
29
+
30
+ // If JSON array string like '["user.*", "order.created"]'
31
+ if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
32
+ try {
33
+ const parsed = JSON.parse(trimmed);
34
+ if (Array.isArray(parsed)) {
35
+ return parsed.map((e) => (typeof e === 'string' ? e.trim() : '')).filter(Boolean);
36
+ }
37
+ } catch (err) {
38
+ // Fall back to comma-separated
39
+ }
40
+ }
41
+
42
+ return trimmed
43
+ .split(',')
44
+ .map((e) => e.trim())
45
+ .filter(Boolean);
46
+ }
47
+
48
+ return ['*'];
49
+ }
50
+
51
+ /**
52
+ * Test whether an event pattern matches an actual event name
53
+ *
54
+ * @param {string} pattern - E.g. '*', 'user.*', 'order.created', 'billing.invoice.paid'
55
+ * @param {string} eventName - E.g. 'user.created', 'order.created'
56
+ * @returns {boolean}
57
+ */
58
+ function matchEvent(pattern, eventName) {
59
+ if (!pattern || !eventName) {
60
+ return false;
61
+ }
62
+
63
+ const p = pattern.trim();
64
+ const e = eventName.trim();
65
+
66
+ // 1. Wildcard matches all
67
+ if (p === '*') {
68
+ return true;
69
+ }
70
+
71
+ // 2. Exact match
72
+ if (p === e) {
73
+ return true;
74
+ }
75
+
76
+ // 3. Glob / Wildcard conversion
77
+ // Convert 'user.*' to regular expression '^user\.[^.]+$' or '^user\..+$'
78
+ // Support prefix, middle, or suffix wildcards safely
79
+ const escaped = p
80
+ .replace(/[.+?^${}()|[\]\\]/g, '\\$&') // escape regex specials except *
81
+ .replace(/\*/g, '.*');
82
+
83
+ const regex = new RegExp(`^${escaped}$`, 'i');
84
+ return regex.test(e);
85
+ }
86
+
87
+ /**
88
+ * Check if an event name matches any pattern in a list
89
+ *
90
+ * @param {string|string[]} patterns
91
+ * @param {string} eventName
92
+ * @returns {boolean}
93
+ */
94
+ function matchesAnyEvent(patterns, eventName) {
95
+ const list = normalizeEvents(patterns);
96
+ if (list.length === 0) {
97
+ return false;
98
+ }
99
+
100
+ for (const pattern of list) {
101
+ if (matchEvent(pattern, eventName)) {
102
+ return true;
103
+ }
104
+ }
105
+
106
+ return false;
107
+ }
108
+
109
+ module.exports = {
110
+ normalizeEvents,
111
+ matchEvent,
112
+ matchesAnyEvent,
113
+ };
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Migration template for webhook_endpoints and webhook_deliveries tables
3
+ * @module plugins/webhooks/migration-template
4
+ */
5
+
6
+ 'use strict';
7
+
8
+ /**
9
+ * Generate migration file content for webhooks tables
10
+ *
11
+ * @param {string} [endpointsTable='webhook_endpoints']
12
+ * @param {string} [deliveriesTable='webhook_deliveries']
13
+ * @returns {string}
14
+ */
15
+ function generateWebhooksMigration(
16
+ endpointsTable = 'webhook_endpoints',
17
+ deliveriesTable = 'webhook_deliveries'
18
+ ) {
19
+ return `/**
20
+ * Migration: Create ${endpointsTable} and ${deliveriesTable} tables
21
+ * Auto-generated by webhooks plugin
22
+ */
23
+
24
+ exports.up = async function(knex) {
25
+ const hasEndpoints = await knex.schema.hasTable('${endpointsTable}');
26
+ if (!hasEndpoints) {
27
+ await knex.schema.createTable('${endpointsTable}', (table) => {
28
+ table.increments('id').primary();
29
+ table.string('name', 255).notNullable();
30
+ table.string('url', 2048).notNullable();
31
+ table.string('secret', 255).notNullable();
32
+ table.text('events').notNullable().defaultTo('["*"]');
33
+ table.boolean('is_active').notNullable().defaultTo(true).index();
34
+ table.string('description', 1000).nullable();
35
+ table.text('headers').nullable();
36
+ table.text('metadata').nullable();
37
+ table.timestamp('created_at').defaultTo(knex.fn.now()).notNullable().index();
38
+ table.timestamp('updated_at').defaultTo(knex.fn.now()).notNullable();
39
+ });
40
+ }
41
+
42
+ const hasDeliveries = await knex.schema.hasTable('${deliveriesTable}');
43
+ if (!hasDeliveries) {
44
+ await knex.schema.createTable('${deliveriesTable}', (table) => {
45
+ table.increments('id').primary();
46
+ table.integer('endpoint_id').unsigned().notNullable().index();
47
+ table.string('event', 255).notNullable().index();
48
+ table.text('payload').notNullable();
49
+ table.integer('status_code').nullable();
50
+ table.boolean('success').notNullable().defaultTo(false).index();
51
+ table.integer('duration_ms').nullable();
52
+ table.text('error').nullable();
53
+ table.text('response_body').nullable();
54
+ table.integer('attempt').notNullable().defaultTo(1);
55
+ table.integer('max_attempts').notNullable().defaultTo(3);
56
+ table.timestamp('next_retry_at').nullable().index();
57
+ table.timestamp('created_at').defaultTo(knex.fn.now()).notNullable().index();
58
+ table.timestamp('updated_at').defaultTo(knex.fn.now()).notNullable();
59
+
60
+ table.index(['endpoint_id', 'created_at']);
61
+ table.index(['event', 'success']);
62
+ });
63
+ }
64
+ };
65
+
66
+ exports.down = async function(knex) {
67
+ await knex.schema.dropTableIfExists('${deliveriesTable}');
68
+ await knex.schema.dropTableIfExists('${endpointsTable}');
69
+ };
70
+ `;
71
+ }
72
+
73
+ module.exports = {
74
+ generateWebhooksMigration,
75
+ };
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Webhooks ORM Models
3
+ * Webspresso ORM model definitions for WebhookEndpoint and WebhookDelivery
4
+ * @module plugins/webhooks/models
5
+ */
6
+
7
+ 'use strict';
8
+
9
+ const { defineModel } = require('../../core/orm/model');
10
+ const { zdb } = require('../../core/orm');
11
+
12
+ /**
13
+ * Create WebhookEndpoint model definition
14
+ *
15
+ * @param {string} [tableName='webhook_endpoints']
16
+ * @returns {import('../../core/orm/types').ModelDefinition}
17
+ */
18
+ function createWebhookEndpointModel(tableName = 'webhook_endpoints') {
19
+ const schema = zdb.schema({
20
+ id: zdb.id(),
21
+ name: zdb.string({ maxLength: 255 }),
22
+ url: zdb.string({ maxLength: 2048 }),
23
+ secret: zdb.string({ maxLength: 255 }),
24
+ events: zdb.text({ default: '["*"]' }),
25
+ is_active: zdb.boolean({ default: true }),
26
+ description: zdb.string({ maxLength: 1000, nullable: true }),
27
+ headers: zdb.json({ nullable: true }),
28
+ metadata: zdb.json({ nullable: true }),
29
+ created_at: zdb.timestamp({ auto: 'create' }),
30
+ updated_at: zdb.timestamp({ auto: 'update' }),
31
+ });
32
+
33
+ return defineModel({
34
+ name: 'WebhookEndpoint',
35
+ table: tableName,
36
+ schema,
37
+ scopes: {
38
+ timestamps: true,
39
+ active: (query) => query.where('is_active', true),
40
+ },
41
+ hidden: ['secret'], // Protect signing secret from serialization leaks
42
+ relations: {
43
+ deliveries: {
44
+ type: 'hasMany',
45
+ model: () => 'WebhookDelivery',
46
+ foreignKey: 'endpoint_id',
47
+ },
48
+ },
49
+ });
50
+ }
51
+
52
+ /**
53
+ * Create WebhookDelivery model definition
54
+ *
55
+ * @param {string} [tableName='webhook_deliveries']
56
+ * @returns {import('../../core/orm/types').ModelDefinition}
57
+ */
58
+ function createWebhookDeliveryModel(tableName = 'webhook_deliveries') {
59
+ const schema = zdb.schema({
60
+ id: zdb.id(),
61
+ endpoint_id: zdb.integer(),
62
+ event: zdb.string({ maxLength: 255 }),
63
+ payload: zdb.text(),
64
+ status_code: zdb.integer({ nullable: true }),
65
+ success: zdb.boolean({ default: false }),
66
+ duration_ms: zdb.integer({ nullable: true }),
67
+ error: zdb.text({ nullable: true }),
68
+ response_body: zdb.text({ nullable: true }),
69
+ attempt: zdb.integer({ default: 1 }),
70
+ max_attempts: zdb.integer({ default: 3 }),
71
+ next_retry_at: zdb.timestamp({ nullable: true }),
72
+ created_at: zdb.timestamp({ auto: 'create' }),
73
+ updated_at: zdb.timestamp({ auto: 'update' }),
74
+ });
75
+
76
+ return defineModel({
77
+ name: 'WebhookDelivery',
78
+ table: tableName,
79
+ schema,
80
+ scopes: {
81
+ timestamps: true,
82
+ successful: (query) => query.where('success', true),
83
+ failed: (query) => query.where('success', false),
84
+ },
85
+ relations: {
86
+ endpoint: {
87
+ type: 'belongsTo',
88
+ model: () => 'WebhookEndpoint',
89
+ foreignKey: 'endpoint_id',
90
+ },
91
+ },
92
+ });
93
+ }
94
+
95
+ module.exports = {
96
+ createWebhookEndpointModel,
97
+ createWebhookDeliveryModel,
98
+ };