webspresso 0.0.99 → 0.0.100

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 (60) hide show
  1. package/.agents/AGENTS.md +2 -2
  2. package/.agents/AUTH.md +246 -0
  3. package/.agents/CLI.md +47 -0
  4. package/.agents/ERRORS.md +59 -0
  5. package/.agents/ORM.md +90 -0
  6. package/.agents/PLUGINS.md +44 -7
  7. package/.agents/QUEUE.md +18 -10
  8. package/.agents/ROUTING.md +33 -0
  9. package/.agents/STREAMING.md +35 -0
  10. package/AGENTS.md +1 -0
  11. package/README.md +145 -93
  12. package/bin/commands/add-docker.js +467 -0
  13. package/bin/commands/deploy-check.js +339 -0
  14. package/bin/webspresso.js +4 -0
  15. package/core/auth/index.js +63 -1
  16. package/core/auth/magic-link.js +170 -0
  17. package/core/auth/manager.js +343 -3
  18. package/core/auth/middleware.js +30 -5
  19. package/core/auth/session-manager.js +255 -0
  20. package/core/auth/sessions.js +351 -0
  21. package/core/auth/tokens.js +1 -0
  22. package/core/auth/totp.js +4 -1
  23. package/core/auth/user-agent.js +161 -0
  24. package/core/orm/index.js +2 -1
  25. package/core/orm/model.js +51 -1
  26. package/core/orm/repository.js +283 -13
  27. package/core/orm/scopes.js +3 -2
  28. package/core/orm/types.js +6 -1
  29. package/core/queue/cron.js +252 -39
  30. package/core/queue/manager.js +20 -2
  31. package/core/queue/scheduler.js +115 -59
  32. package/core/ssr/stream.js +62 -2
  33. package/index.d.ts +274 -14
  34. package/index.js +4 -0
  35. package/package.json +7 -5
  36. package/plugins/admin-panel/modules/admin-users.js +1 -1
  37. package/plugins/audit-log/admin-component.js +54 -54
  38. package/plugins/audit-log/index.js +2 -2
  39. package/plugins/content/client/inline-edit.js +2 -46
  40. package/plugins/cors.js +13 -2
  41. package/plugins/email/auth-bridge.js +66 -0
  42. package/plugins/email/bundled-templates.js +15 -0
  43. package/plugins/email/templates/auth-magic-link.mjml +13 -0
  44. package/plugins/file-manager/admin-component.js +49 -49
  45. package/plugins/file-manager/index.js +4 -4
  46. package/plugins/index.js +2 -0
  47. package/plugins/webhooks/admin-component.js +608 -0
  48. package/plugins/webhooks/api-handlers.js +237 -0
  49. package/plugins/webhooks/crypto.js +144 -0
  50. package/plugins/webhooks/dispatcher.js +708 -0
  51. package/plugins/webhooks/index.js +263 -0
  52. package/plugins/webhooks/matcher.js +113 -0
  53. package/plugins/webhooks/migration-template.js +75 -0
  54. package/plugins/webhooks/models.js +98 -0
  55. package/plugins/webhooks/sender.js +176 -0
  56. package/src/bundle/index.js +2 -1
  57. package/src/file-router.js +173 -4
  58. package/src/helpers.js +7 -1
  59. package/src/njk-frontmatter.js +3 -0
  60. package/src/routing/cascading.js +112 -0
@@ -0,0 +1,237 @@
1
+ /**
2
+ * Webhooks Admin API Handlers
3
+ * REST endpoints for managing endpoints, inspecting delivery history, pinging, and retrying
4
+ * @module plugins/webhooks/api-handlers
5
+ */
6
+
7
+ 'use strict';
8
+
9
+ /**
10
+ * Create Webhook API route handlers
11
+ *
12
+ * @param {import('./dispatcher').WebhookDispatcher} dispatcher
13
+ * @param {Object} [options]
14
+ * @returns {Record<string, Function>}
15
+ */
16
+ function createWebhookApiHandlers(dispatcher, options = {}) {
17
+ /**
18
+ * GET /endpoints — List all webhook endpoints
19
+ */
20
+ async function listEndpointsHandler(req, res) {
21
+ try {
22
+ const active = req.query.active !== undefined ? req.query.active === 'true' : undefined;
23
+ const search = req.query.search || req.query.q || undefined;
24
+ const endpoints = await dispatcher.listEndpoints({ active, search });
25
+
26
+ // Enrich with delivery counts if knex available
27
+ const kx = dispatcher.resolveKnex();
28
+ let enriched = endpoints;
29
+ if (kx && endpoints.length > 0) {
30
+ try {
31
+ const stats = await kx(dispatcher.deliveriesTable)
32
+ .select('endpoint_id')
33
+ .count('* as total_deliveries')
34
+ .sum({ success_count: kx.raw('CASE WHEN success = 1 OR success = true THEN 1 ELSE 0 END') })
35
+ .groupBy('endpoint_id');
36
+
37
+ const statsMap = new Map();
38
+ for (const s of stats) {
39
+ statsMap.set(String(s.endpoint_id), {
40
+ totalDeliveries: Number(s.total_deliveries || 0),
41
+ successCount: Number(s.success_count || 0),
42
+ });
43
+ }
44
+
45
+ enriched = endpoints.map((ep) => {
46
+ const st = statsMap.get(String(ep.id)) || { totalDeliveries: 0, successCount: 0 };
47
+ return {
48
+ ...ep,
49
+ stats: {
50
+ total: st.totalDeliveries,
51
+ successful: st.successCount,
52
+ failed: st.totalDeliveries - st.successCount,
53
+ successRate: st.totalDeliveries > 0 ? Math.round((st.successCount / st.totalDeliveries) * 100) : 100,
54
+ },
55
+ };
56
+ });
57
+ } catch (statsErr) {
58
+ // Fallback if aggregation fails
59
+ }
60
+ }
61
+
62
+ return res.status(200).json({
63
+ items: enriched,
64
+ total: enriched.length,
65
+ });
66
+ } catch (err) {
67
+ return res.status(500).json({ error: err.message || 'Failed to list webhook endpoints' });
68
+ }
69
+ }
70
+
71
+ /**
72
+ * POST /endpoints — Create new endpoint
73
+ */
74
+ async function createEndpointHandler(req, res) {
75
+ try {
76
+ const body = req.body || {};
77
+ if (!body.name || !body.url) {
78
+ return res.status(400).json({ error: 'Endpoint name and URL are required.' });
79
+ }
80
+
81
+ const created = await dispatcher.createEndpoint(body);
82
+ return res.status(201).json(created);
83
+ } catch (err) {
84
+ return res.status(400).json({ error: err.message || 'Failed to create webhook endpoint' });
85
+ }
86
+ }
87
+
88
+ /**
89
+ * GET /endpoints/:id — Get endpoint details
90
+ */
91
+ async function getEndpointHandler(req, res) {
92
+ try {
93
+ const id = req.params.id;
94
+ const endpoint = await dispatcher.getEndpoint(id);
95
+ if (!endpoint) {
96
+ return res.status(404).json({ error: `Webhook endpoint #${id} not found` });
97
+ }
98
+ return res.status(200).json(endpoint);
99
+ } catch (err) {
100
+ return res.status(500).json({ error: err.message || 'Failed to get webhook endpoint' });
101
+ }
102
+ }
103
+
104
+ /**
105
+ * PUT/PATCH /endpoints/:id — Update endpoint
106
+ */
107
+ async function updateEndpointHandler(req, res) {
108
+ try {
109
+ const id = req.params.id;
110
+ const body = req.body || {};
111
+ const updated = await dispatcher.updateEndpoint(id, body);
112
+ if (!updated) {
113
+ return res.status(404).json({ error: `Webhook endpoint #${id} not found` });
114
+ }
115
+ return res.status(200).json(updated);
116
+ } catch (err) {
117
+ return res.status(400).json({ error: err.message || 'Failed to update webhook endpoint' });
118
+ }
119
+ }
120
+
121
+ /**
122
+ * DELETE /endpoints/:id — Delete endpoint
123
+ */
124
+ async function deleteEndpointHandler(req, res) {
125
+ try {
126
+ const id = req.params.id;
127
+ const deleted = await dispatcher.deleteEndpoint(id);
128
+ if (!deleted) {
129
+ return res.status(404).json({ error: `Webhook endpoint #${id} not found` });
130
+ }
131
+ return res.status(200).json({ success: true, message: `Endpoint #${id} deleted.` });
132
+ } catch (err) {
133
+ return res.status(500).json({ error: err.message || 'Failed to delete webhook endpoint' });
134
+ }
135
+ }
136
+
137
+ /**
138
+ * POST /endpoints/:id/ping — Send test ping
139
+ */
140
+ async function pingEndpointHandler(req, res) {
141
+ try {
142
+ const id = req.params.id;
143
+ const result = await dispatcher.pingEndpoint(id);
144
+ return res.status(200).json(result);
145
+ } catch (err) {
146
+ return res.status(400).json({ error: err.message || 'Failed to ping webhook endpoint' });
147
+ }
148
+ }
149
+
150
+ /**
151
+ * GET /deliveries — List delivery logs
152
+ */
153
+ async function listDeliveriesHandler(req, res) {
154
+ try {
155
+ const endpointId = req.query.endpointId || req.query.endpoint_id;
156
+ const event = req.query.event;
157
+ const success = req.query.success;
158
+ const page = req.query.page;
159
+ const limit = req.query.limit || req.query.perPage;
160
+
161
+ const data = await dispatcher.listDeliveries({
162
+ endpointId,
163
+ event,
164
+ success,
165
+ page,
166
+ limit,
167
+ });
168
+
169
+ return res.status(200).json(data);
170
+ } catch (err) {
171
+ return res.status(500).json({ error: err.message || 'Failed to list webhook deliveries' });
172
+ }
173
+ }
174
+
175
+ /**
176
+ * GET /deliveries/:id — Get single delivery log
177
+ */
178
+ async function getDeliveryHandler(req, res) {
179
+ try {
180
+ const id = req.params.id;
181
+ const item = await dispatcher.getDelivery(id);
182
+ if (!item) {
183
+ return res.status(404).json({ error: `Webhook delivery #${id} not found` });
184
+ }
185
+ return res.status(200).json(item);
186
+ } catch (err) {
187
+ return res.status(500).json({ error: err.message || 'Failed to get webhook delivery' });
188
+ }
189
+ }
190
+
191
+ /**
192
+ * POST /deliveries/:id/retry — Retry failed delivery
193
+ */
194
+ async function retryDeliveryHandler(req, res) {
195
+ try {
196
+ const id = req.params.id;
197
+ const result = await dispatcher.retryDelivery(id);
198
+ return res.status(200).json(result);
199
+ } catch (err) {
200
+ return res.status(400).json({ error: err.message || 'Failed to retry webhook delivery' });
201
+ }
202
+ }
203
+
204
+ /**
205
+ * POST /deliveries/purge — Purge old deliveries
206
+ */
207
+ async function purgeDeliveriesHandler(req, res) {
208
+ try {
209
+ const days = parseInt(req.body?.days || req.query?.days, 10) || 30;
210
+ const purgedCount = await dispatcher.purgeDeliveries({ days });
211
+ return res.status(200).json({
212
+ success: true,
213
+ purged: purgedCount,
214
+ message: `Purged ${purgedCount} webhook deliveries older than ${days} days.`,
215
+ });
216
+ } catch (err) {
217
+ return res.status(500).json({ error: err.message || 'Failed to purge webhook deliveries' });
218
+ }
219
+ }
220
+
221
+ return {
222
+ listEndpointsHandler,
223
+ createEndpointHandler,
224
+ getEndpointHandler,
225
+ updateEndpointHandler,
226
+ deleteEndpointHandler,
227
+ pingEndpointHandler,
228
+ listDeliveriesHandler,
229
+ getDeliveryHandler,
230
+ retryDeliveryHandler,
231
+ purgeDeliveriesHandler,
232
+ };
233
+ }
234
+
235
+ module.exports = {
236
+ createWebhookApiHandlers,
237
+ };
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Webhooks Cryptography & Signature Module
3
+ * Zero-dependency HMAC-SHA256 signing, constant-time verification & secret generation
4
+ * @module plugins/webhooks/crypto
5
+ */
6
+
7
+ 'use strict';
8
+
9
+ const crypto = require('crypto');
10
+
11
+ /**
12
+ * Generate a cryptographically secure webhook signing secret
13
+ *
14
+ * @param {string} [prefix='whsec_']
15
+ * @param {number} [byteLength=24]
16
+ * @returns {string} E.g. 'whsec_e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934c'
17
+ */
18
+ function generateSecret(prefix = 'whsec_', byteLength = 24) {
19
+ const bytes = crypto.randomBytes(byteLength).toString('hex');
20
+ return `${prefix || ''}${bytes}`;
21
+ }
22
+
23
+ /**
24
+ * Perform constant-time string comparison to prevent timing attacks
25
+ *
26
+ * @param {string} a
27
+ * @param {string} b
28
+ * @returns {boolean}
29
+ */
30
+ function safeCompare(a, b) {
31
+ if (typeof a !== 'string' || typeof b !== 'string') {
32
+ return false;
33
+ }
34
+ const bufA = Buffer.from(a, 'utf8');
35
+ const bufB = Buffer.from(b, 'utf8');
36
+ if (bufA.length !== bufB.length) {
37
+ return false;
38
+ }
39
+ return crypto.timingSafeEqual(bufA, bufB);
40
+ }
41
+
42
+ /**
43
+ * Sign a webhook payload using HMAC-SHA256
44
+ *
45
+ * @param {string|Object} payload - Raw payload string or serializable object
46
+ * @param {string} secret - Secret key used for signing
47
+ * @param {Object} [options]
48
+ * @param {number} [options.timestamp] - Unix timestamp in seconds
49
+ * @returns {{ signature: string, timestampedSignature: string, timestamp: number, hex: string }}
50
+ */
51
+ function signPayload(payload, secret, options = {}) {
52
+ if (!secret || typeof secret !== 'string') {
53
+ throw new Error('A valid string secret is required to sign webhook payloads.');
54
+ }
55
+
56
+ const payloadStr = typeof payload === 'string' ? payload : JSON.stringify(payload);
57
+ const timestamp = typeof options.timestamp === 'number'
58
+ ? options.timestamp
59
+ : Math.floor(Date.now() / 1000);
60
+
61
+ // 1. Direct payload HMAC-SHA256 (compatible with GitHub / standard webhooks)
62
+ const hex = crypto.createHmac('sha256', secret).update(payloadStr, 'utf8').digest('hex');
63
+ const signature = `sha256=${hex}`;
64
+
65
+ // 2. Timestamped HMAC-SHA256 (compatible with Stripe / Svix / Slack)
66
+ const toSign = `${timestamp}.${payloadStr}`;
67
+ const timestampedHex = crypto.createHmac('sha256', secret).update(toSign, 'utf8').digest('hex');
68
+ const timestampedSignature = `t=${timestamp},v1=${timestampedHex}`;
69
+
70
+ return {
71
+ signature,
72
+ timestampedSignature,
73
+ timestamp,
74
+ hex,
75
+ };
76
+ }
77
+
78
+ /**
79
+ * Verify a webhook payload against an incoming signature header
80
+ *
81
+ * @param {string|Object} payload
82
+ * @param {string} signatureHeader
83
+ * @param {string} secret
84
+ * @param {Object} [options]
85
+ * @param {number} [options.tolerance=300] - Allowed timestamp drift in seconds (default 5 minutes)
86
+ * @returns {boolean}
87
+ */
88
+ function verifySignature(payload, signatureHeader, secret, options = {}) {
89
+ if (!signatureHeader || typeof signatureHeader !== 'string' || !secret) {
90
+ return false;
91
+ }
92
+
93
+ const payloadStr = typeof payload === 'string' ? payload : JSON.stringify(payload);
94
+ const tolerance = options.tolerance !== undefined ? options.tolerance : 300;
95
+
96
+ // Case A: Timestamped header format (t=1234567890,v1=abcdef...)
97
+ if (signatureHeader.includes('t=') && signatureHeader.includes('v1=')) {
98
+ const parts = signatureHeader.split(',');
99
+ let timestamp = null;
100
+ const signatures = [];
101
+
102
+ for (const part of parts) {
103
+ const [key, val] = part.trim().split('=');
104
+ if (key === 't') {
105
+ timestamp = parseInt(val, 10);
106
+ } else if (key === 'v1' && val) {
107
+ signatures.push(val);
108
+ }
109
+ }
110
+
111
+ if (!timestamp || isNaN(timestamp) || signatures.length === 0) {
112
+ return false;
113
+ }
114
+
115
+ // Check tolerance if enabled
116
+ if (tolerance > 0) {
117
+ const now = Math.floor(Date.now() / 1000);
118
+ if (Math.abs(now - timestamp) > tolerance) {
119
+ return false;
120
+ }
121
+ }
122
+
123
+ const toSign = `${timestamp}.${payloadStr}`;
124
+ const expectedHex = crypto.createHmac('sha256', secret).update(toSign, 'utf8').digest('hex');
125
+
126
+ return signatures.some((sig) => safeCompare(sig, expectedHex));
127
+ }
128
+
129
+ // Case B: Simple format (sha256=abcdef... or raw hex)
130
+ let receivedHex = signatureHeader.trim();
131
+ if (receivedHex.startsWith('sha256=')) {
132
+ receivedHex = receivedHex.slice(7);
133
+ }
134
+
135
+ const expectedHex = crypto.createHmac('sha256', secret).update(payloadStr, 'utf8').digest('hex');
136
+ return safeCompare(receivedHex, expectedHex);
137
+ }
138
+
139
+ module.exports = {
140
+ generateSecret,
141
+ safeCompare,
142
+ signPayload,
143
+ verifySignature,
144
+ };