foundrycms 0.1.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 (75) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +152 -0
  3. package/dist/__tests__/foundry.test.d.ts +2 -0
  4. package/dist/__tests__/foundry.test.d.ts.map +1 -0
  5. package/dist/__tests__/foundry.test.js +1013 -0
  6. package/dist/__tests__/foundry.test.js.map +1 -0
  7. package/dist/config-manager.d.ts +33 -0
  8. package/dist/config-manager.d.ts.map +1 -0
  9. package/dist/config-manager.js +169 -0
  10. package/dist/config-manager.js.map +1 -0
  11. package/dist/hook-system.d.ts +61 -0
  12. package/dist/hook-system.d.ts.map +1 -0
  13. package/dist/hook-system.js +114 -0
  14. package/dist/hook-system.js.map +1 -0
  15. package/dist/index.d.ts +47 -0
  16. package/dist/index.d.ts.map +1 -0
  17. package/dist/index.js +82 -0
  18. package/dist/index.js.map +1 -0
  19. package/dist/page-builder/element-registry.d.ts +47 -0
  20. package/dist/page-builder/element-registry.d.ts.map +1 -0
  21. package/dist/page-builder/element-registry.js +98 -0
  22. package/dist/page-builder/element-registry.js.map +1 -0
  23. package/dist/page-builder/elements/index.d.ts +22 -0
  24. package/dist/page-builder/elements/index.d.ts.map +1 -0
  25. package/dist/page-builder/elements/index.js +770 -0
  26. package/dist/page-builder/elements/index.js.map +1 -0
  27. package/dist/page-builder/renderer.d.ts +14 -0
  28. package/dist/page-builder/renderer.d.ts.map +1 -0
  29. package/dist/page-builder/renderer.js +240 -0
  30. package/dist/page-builder/renderer.js.map +1 -0
  31. package/dist/page-builder/serializer.d.ts +1220 -0
  32. package/dist/page-builder/serializer.d.ts.map +1 -0
  33. package/dist/page-builder/serializer.js +111 -0
  34. package/dist/page-builder/serializer.js.map +1 -0
  35. package/dist/page-builder/template-studio.d.ts +37 -0
  36. package/dist/page-builder/template-studio.d.ts.map +1 -0
  37. package/dist/page-builder/template-studio.js +923 -0
  38. package/dist/page-builder/template-studio.js.map +1 -0
  39. package/dist/page-builder/types.d.ts +99 -0
  40. package/dist/page-builder/types.d.ts.map +1 -0
  41. package/dist/page-builder/types.js +5 -0
  42. package/dist/page-builder/types.js.map +1 -0
  43. package/dist/plugin-system.d.ts +128 -0
  44. package/dist/plugin-system.d.ts.map +1 -0
  45. package/dist/plugin-system.js +252 -0
  46. package/dist/plugin-system.js.map +1 -0
  47. package/dist/plugins/communication.d.ts +6 -0
  48. package/dist/plugins/communication.d.ts.map +1 -0
  49. package/dist/plugins/communication.js +922 -0
  50. package/dist/plugins/communication.js.map +1 -0
  51. package/dist/plugins/core.d.ts +6 -0
  52. package/dist/plugins/core.d.ts.map +1 -0
  53. package/dist/plugins/core.js +675 -0
  54. package/dist/plugins/core.js.map +1 -0
  55. package/dist/plugins/growth.d.ts +6 -0
  56. package/dist/plugins/growth.d.ts.map +1 -0
  57. package/dist/plugins/growth.js +668 -0
  58. package/dist/plugins/growth.js.map +1 -0
  59. package/dist/plugins/index.d.ts +8 -0
  60. package/dist/plugins/index.d.ts.map +1 -0
  61. package/dist/plugins/index.js +43 -0
  62. package/dist/plugins/index.js.map +1 -0
  63. package/dist/plugins/operations.d.ts +7 -0
  64. package/dist/plugins/operations.d.ts.map +1 -0
  65. package/dist/plugins/operations.js +930 -0
  66. package/dist/plugins/operations.js.map +1 -0
  67. package/dist/theme/presets.d.ts +8 -0
  68. package/dist/theme/presets.d.ts.map +1 -0
  69. package/dist/theme/presets.js +257 -0
  70. package/dist/theme/presets.js.map +1 -0
  71. package/dist/theme/types.d.ts +83 -0
  72. package/dist/theme/types.d.ts.map +1 -0
  73. package/dist/theme/types.js +5 -0
  74. package/dist/theme/types.js.map +1 -0
  75. package/package.json +38 -0
@@ -0,0 +1,922 @@
1
+ import { z } from 'zod';
2
+ // ---------------------------------------------------------------------------
3
+ // Relay — Messaging Hub
4
+ // ---------------------------------------------------------------------------
5
+ export const relayPlugin = {
6
+ id: '@foundry/relay',
7
+ name: 'Relay',
8
+ version: '1.0.0',
9
+ description: 'Messaging hub with email, SMS, in-app notifications, and templated communication',
10
+ adminPages: [
11
+ {
12
+ path: 'relay',
13
+ label: 'Messaging',
14
+ icon: 'send',
15
+ component: 'RelayDashboardPage',
16
+ order: 45,
17
+ permission: 'relay:view',
18
+ children: [
19
+ {
20
+ path: 'inbox',
21
+ label: 'Inbox',
22
+ component: 'RelayInboxPage',
23
+ permission: 'relay:view',
24
+ },
25
+ {
26
+ path: 'templates',
27
+ label: 'Templates',
28
+ component: 'RelayTemplatesPage',
29
+ permission: 'relay:manage',
30
+ },
31
+ {
32
+ path: 'campaigns',
33
+ label: 'Campaigns',
34
+ component: 'RelayCampaignsPage',
35
+ permission: 'relay:campaigns',
36
+ },
37
+ {
38
+ path: 'logs',
39
+ label: 'Send Log',
40
+ component: 'RelayLogsPage',
41
+ permission: 'relay:view',
42
+ },
43
+ {
44
+ path: 'settings',
45
+ label: 'Settings',
46
+ component: 'RelaySettingsPage',
47
+ permission: 'relay:settings',
48
+ },
49
+ ],
50
+ },
51
+ ],
52
+ apiRoutes: [
53
+ {
54
+ method: 'POST',
55
+ path: '/api/relay/send',
56
+ handler: 'relay.sendMessage',
57
+ middleware: ['auth', 'permission:relay:send'],
58
+ description: 'Send a message via email, SMS, or in-app',
59
+ },
60
+ {
61
+ method: 'POST',
62
+ path: '/api/relay/send-template',
63
+ handler: 'relay.sendTemplate',
64
+ middleware: ['auth', 'permission:relay:send'],
65
+ description: 'Send a templated message',
66
+ },
67
+ {
68
+ method: 'GET',
69
+ path: '/api/relay/templates',
70
+ handler: 'relay.listTemplates',
71
+ middleware: ['auth'],
72
+ description: 'List message templates',
73
+ },
74
+ {
75
+ method: 'POST',
76
+ path: '/api/relay/templates',
77
+ handler: 'relay.createTemplate',
78
+ middleware: ['auth', 'permission:relay:manage'],
79
+ description: 'Create a message template',
80
+ },
81
+ {
82
+ method: 'GET',
83
+ path: '/api/relay/notifications',
84
+ handler: 'relay.getNotifications',
85
+ middleware: ['auth'],
86
+ description: 'Get in-app notifications for current user',
87
+ },
88
+ {
89
+ method: 'PATCH',
90
+ path: '/api/relay/notifications/:id/read',
91
+ handler: 'relay.markRead',
92
+ middleware: ['auth'],
93
+ description: 'Mark a notification as read',
94
+ },
95
+ {
96
+ method: 'POST',
97
+ path: '/api/relay/webhooks/email',
98
+ handler: 'relay.emailWebhook',
99
+ description: 'Incoming email webhook (SendGrid/Postmark)',
100
+ },
101
+ ],
102
+ widgets: [
103
+ {
104
+ id: 'relay-notifications',
105
+ name: 'Notifications',
106
+ component: 'RelayNotificationsWidget',
107
+ areas: ['dashboard', 'sidebar'],
108
+ defaultSize: { width: 3, height: 4 },
109
+ },
110
+ {
111
+ id: 'relay-send-stats',
112
+ name: 'Message Stats',
113
+ component: 'RelaySendStatsWidget',
114
+ areas: ['dashboard'],
115
+ defaultSize: { width: 3, height: 2 },
116
+ },
117
+ ],
118
+ hooks: [
119
+ {
120
+ hook: 'content:after_save',
121
+ handler: (payload) => {
122
+ // Auto-send notifications for important content events
123
+ if (payload.content?.type === 'notification') {
124
+ const notification = payload.content;
125
+ // Queue the notification for delivery
126
+ _notificationQueue.push({
127
+ id: notification.id ?? `notif-${Date.now()}`,
128
+ channel: notification.channel ?? 'in-app',
129
+ recipient: notification.recipient,
130
+ subject: notification.subject,
131
+ body: notification.body,
132
+ templateId: notification.templateId,
133
+ templateData: notification.templateData,
134
+ status: 'queued',
135
+ createdAt: new Date().toISOString(),
136
+ });
137
+ }
138
+ },
139
+ priority: 5,
140
+ },
141
+ ],
142
+ settingsSchema: z.object({
143
+ emailProvider: z.enum(['sendgrid', 'postmark', 'ses', 'smtp']).default('smtp'),
144
+ emailApiKey: z.string().optional(),
145
+ smtpHost: z.string().optional(),
146
+ smtpPort: z.number().optional(),
147
+ smtpUser: z.string().optional(),
148
+ smtpPass: z.string().optional(),
149
+ fromEmail: z.string().email().optional(),
150
+ fromName: z.string().default(''),
151
+ smsProvider: z.enum(['twilio', 'vonage', 'none']).default('none'),
152
+ smsApiKey: z.string().optional(),
153
+ smsFromNumber: z.string().optional(),
154
+ enableInAppNotifications: z.boolean().default(true),
155
+ notificationRetentionDays: z.number().min(7).max(365).default(90),
156
+ }),
157
+ async onActivate(_core) {
158
+ _notificationQueue.length = 0;
159
+ },
160
+ async onDeactivate(_core) {
161
+ _notificationQueue.length = 0;
162
+ },
163
+ };
164
+ const _notificationQueue = [];
165
+ // ---------------------------------------------------------------------------
166
+ // Canvas — Form Builder
167
+ // ---------------------------------------------------------------------------
168
+ export const canvasPlugin = {
169
+ id: '@foundry/canvas',
170
+ name: 'Canvas',
171
+ version: '1.0.0',
172
+ description: 'Drag-and-drop form builder with conditional logic, file uploads, and submission management',
173
+ adminPages: [
174
+ {
175
+ path: 'canvas',
176
+ label: 'Forms',
177
+ icon: 'clipboard',
178
+ component: 'CanvasDashboardPage',
179
+ order: 18,
180
+ permission: 'canvas:view',
181
+ children: [
182
+ {
183
+ path: 'forms',
184
+ label: 'All Forms',
185
+ component: 'CanvasFormsPage',
186
+ permission: 'canvas:view',
187
+ },
188
+ {
189
+ path: 'forms/new',
190
+ label: 'New Form',
191
+ component: 'CanvasFormEditorPage',
192
+ permission: 'canvas:create',
193
+ },
194
+ {
195
+ path: 'forms/:id/edit',
196
+ label: 'Edit Form',
197
+ component: 'CanvasFormEditorPage',
198
+ permission: 'canvas:edit',
199
+ },
200
+ {
201
+ path: 'submissions',
202
+ label: 'Submissions',
203
+ component: 'CanvasSubmissionsPage',
204
+ permission: 'canvas:view',
205
+ },
206
+ ],
207
+ },
208
+ ],
209
+ apiRoutes: [
210
+ {
211
+ method: 'GET',
212
+ path: '/api/canvas/forms',
213
+ handler: 'canvas.listForms',
214
+ middleware: ['auth'],
215
+ description: 'List all forms',
216
+ },
217
+ {
218
+ method: 'POST',
219
+ path: '/api/canvas/forms',
220
+ handler: 'canvas.createForm',
221
+ middleware: ['auth', 'permission:canvas:create'],
222
+ description: 'Create a new form',
223
+ },
224
+ {
225
+ method: 'GET',
226
+ path: '/api/canvas/forms/:id',
227
+ handler: 'canvas.getForm',
228
+ description: 'Get form definition (public for rendering)',
229
+ },
230
+ {
231
+ method: 'POST',
232
+ path: '/api/canvas/forms/:id/submit',
233
+ handler: 'canvas.submitForm',
234
+ middleware: ['rateLimit:forms'],
235
+ description: 'Submit a form response (public)',
236
+ },
237
+ {
238
+ method: 'GET',
239
+ path: '/api/canvas/forms/:id/submissions',
240
+ handler: 'canvas.listSubmissions',
241
+ middleware: ['auth', 'permission:canvas:view'],
242
+ description: 'List submissions for a form',
243
+ },
244
+ {
245
+ method: 'POST',
246
+ path: '/api/canvas/forms/:id/submissions/export',
247
+ handler: 'canvas.exportSubmissions',
248
+ middleware: ['auth', 'permission:canvas:export'],
249
+ description: 'Export submissions as CSV',
250
+ },
251
+ ],
252
+ pageElements: [
253
+ {
254
+ type: 'form-embed',
255
+ name: 'Form',
256
+ category: 'interactive',
257
+ component: 'CanvasFormEmbed',
258
+ settingsSchema: z.object({
259
+ formId: z.string(),
260
+ theme: z.enum(['default', 'minimal', 'bordered']).default('default'),
261
+ submitButtonText: z.string().default('Submit'),
262
+ successMessage: z.string().default('Thank you for your submission!'),
263
+ redirectUrl: z.string().optional(),
264
+ showLabels: z.boolean().default(true),
265
+ layout: z.enum(['vertical', 'horizontal', 'inline']).default('vertical'),
266
+ }),
267
+ },
268
+ {
269
+ type: 'contact-form',
270
+ name: 'Contact Form',
271
+ category: 'interactive',
272
+ component: 'CanvasContactForm',
273
+ settingsSchema: z.object({
274
+ recipientEmail: z.string().email().optional(),
275
+ fields: z.array(z.enum(['name', 'email', 'phone', 'company', 'message', 'subject'])).default(['name', 'email', 'message']),
276
+ requireAll: z.boolean().default(false),
277
+ honeypot: z.boolean().default(true),
278
+ submitText: z.string().default('Send Message'),
279
+ successMessage: z.string().default('Thank you! We will get back to you soon.'),
280
+ }),
281
+ },
282
+ ],
283
+ widgets: [
284
+ {
285
+ id: 'canvas-recent-submissions',
286
+ name: 'Recent Submissions',
287
+ component: 'CanvasRecentSubmissionsWidget',
288
+ areas: ['dashboard', 'sidebar'],
289
+ defaultSize: { width: 4, height: 3 },
290
+ },
291
+ {
292
+ id: 'canvas-form-stats',
293
+ name: 'Form Stats',
294
+ component: 'CanvasFormStatsWidget',
295
+ areas: ['dashboard'],
296
+ defaultSize: { width: 2, height: 2 },
297
+ },
298
+ ],
299
+ hooks: [
300
+ {
301
+ hook: 'content:before_save',
302
+ handler: (payload) => {
303
+ if (payload.content?.type === 'form_submission') {
304
+ const submission = payload.content;
305
+ // Sanitize all text fields
306
+ if (submission.data) {
307
+ for (const [key, value] of Object.entries(submission.data)) {
308
+ if (typeof value === 'string') {
309
+ submission.data[key] = sanitizeFormInput(value);
310
+ }
311
+ }
312
+ }
313
+ // Check honeypot
314
+ if (submission.data?._hp && submission.data._hp.length > 0) {
315
+ throw new Error('Canvas: Spam detected');
316
+ }
317
+ // Add metadata
318
+ submission.submittedAt = new Date().toISOString();
319
+ submission.ipHash = submission._ip
320
+ ? simpleHash(submission._ip)
321
+ : undefined;
322
+ delete submission._ip;
323
+ delete submission.data?._hp;
324
+ }
325
+ return payload;
326
+ },
327
+ priority: 3,
328
+ },
329
+ {
330
+ hook: 'content:after_save',
331
+ handler: (payload) => {
332
+ if (payload.content?.type === 'form_submission') {
333
+ // Notify via relay if configured
334
+ if (payload.content.notifyEmail) {
335
+ // Would trigger relay notification
336
+ }
337
+ }
338
+ },
339
+ priority: 10,
340
+ },
341
+ ],
342
+ settingsSchema: z.object({
343
+ maxFileUploadMb: z.number().min(1).max(100).default(10),
344
+ allowedFileTypes: z.array(z.string()).default(['.pdf', '.jpg', '.png', '.doc', '.docx']),
345
+ spamProtection: z.enum(['honeypot', 'recaptcha', 'turnstile', 'none']).default('honeypot'),
346
+ recaptchaSiteKey: z.string().optional(),
347
+ recaptchaSecretKey: z.string().optional(),
348
+ storeSubmissions: z.boolean().default(true),
349
+ submissionRetentionDays: z.number().min(30).max(730).default(365),
350
+ notifyOnSubmission: z.boolean().default(true),
351
+ defaultNotifyEmail: z.string().email().optional(),
352
+ }),
353
+ async onActivate(core) {
354
+ // Wire form submissions to CRM contacts if compass is active
355
+ core.hooks.on('content:after_save', (payload) => {
356
+ if (payload.content?.type === 'form_submission' && core.plugins.isActive('@foundry/compass')) {
357
+ const data = payload.content.data ?? {};
358
+ if (data.email) {
359
+ core.hooks.emit('content:before_save', {
360
+ content: {
361
+ type: 'contact',
362
+ email: data.email,
363
+ name: data.name ?? data.fullName ?? '',
364
+ phone: data.phone,
365
+ company: data.company,
366
+ source: 'form',
367
+ sourceFormId: payload.content.formId,
368
+ lifecycleStage: 'subscriber',
369
+ },
370
+ });
371
+ }
372
+ }
373
+ });
374
+ },
375
+ async onDeactivate(_core) { },
376
+ };
377
+ function sanitizeFormInput(str) {
378
+ return str
379
+ .replace(/</g, '&lt;')
380
+ .replace(/>/g, '&gt;')
381
+ .replace(/"/g, '&quot;')
382
+ .replace(/'/g, '&#x27;')
383
+ .trim()
384
+ .substring(0, 10000); // limit field length
385
+ }
386
+ function simpleHash(str) {
387
+ let hash = 0;
388
+ for (let i = 0; i < str.length; i++) {
389
+ const char = str.charCodeAt(i);
390
+ hash = ((hash << 5) - hash) + char;
391
+ hash |= 0;
392
+ }
393
+ return Math.abs(hash).toString(36);
394
+ }
395
+ // ---------------------------------------------------------------------------
396
+ // Market — E-commerce
397
+ // ---------------------------------------------------------------------------
398
+ export const marketPlugin = {
399
+ id: '@foundry/market',
400
+ name: 'Market',
401
+ version: '1.0.0',
402
+ description: 'E-commerce with product catalog, shopping cart, Stripe checkout, and order management',
403
+ requires: ['@foundry/ledger'],
404
+ adminPages: [
405
+ {
406
+ path: 'market',
407
+ label: 'Store',
408
+ icon: 'shopping-bag',
409
+ component: 'MarketDashboardPage',
410
+ order: 22,
411
+ permission: 'market:view',
412
+ children: [
413
+ {
414
+ path: 'products',
415
+ label: 'Products',
416
+ component: 'MarketProductsPage',
417
+ permission: 'market:view',
418
+ },
419
+ {
420
+ path: 'products/new',
421
+ label: 'New Product',
422
+ component: 'MarketProductEditorPage',
423
+ permission: 'market:create',
424
+ },
425
+ {
426
+ path: 'orders',
427
+ label: 'Orders',
428
+ component: 'MarketOrdersPage',
429
+ permission: 'market:orders',
430
+ },
431
+ {
432
+ path: 'orders/:id',
433
+ label: 'Order Detail',
434
+ component: 'MarketOrderDetailPage',
435
+ permission: 'market:orders',
436
+ },
437
+ {
438
+ path: 'coupons',
439
+ label: 'Coupons',
440
+ component: 'MarketCouponsPage',
441
+ permission: 'market:manage',
442
+ },
443
+ {
444
+ path: 'categories',
445
+ label: 'Categories',
446
+ component: 'MarketCategoriesPage',
447
+ permission: 'market:manage',
448
+ },
449
+ {
450
+ path: 'shipping',
451
+ label: 'Shipping',
452
+ component: 'MarketShippingPage',
453
+ permission: 'market:manage',
454
+ },
455
+ ],
456
+ },
457
+ ],
458
+ apiRoutes: [
459
+ {
460
+ method: 'GET',
461
+ path: '/api/market/products',
462
+ handler: 'market.listProducts',
463
+ description: 'List products with filters (public)',
464
+ },
465
+ {
466
+ method: 'GET',
467
+ path: '/api/market/products/:slug',
468
+ handler: 'market.getProduct',
469
+ description: 'Get product by slug (public)',
470
+ },
471
+ {
472
+ method: 'POST',
473
+ path: '/api/market/products',
474
+ handler: 'market.createProduct',
475
+ middleware: ['auth', 'permission:market:create'],
476
+ description: 'Create a product',
477
+ },
478
+ {
479
+ method: 'POST',
480
+ path: '/api/market/cart',
481
+ handler: 'market.addToCart',
482
+ description: 'Add item to cart (public)',
483
+ },
484
+ {
485
+ method: 'GET',
486
+ path: '/api/market/cart',
487
+ handler: 'market.getCart',
488
+ description: 'Get current cart (public)',
489
+ },
490
+ {
491
+ method: 'DELETE',
492
+ path: '/api/market/cart/:itemId',
493
+ handler: 'market.removeFromCart',
494
+ description: 'Remove item from cart',
495
+ },
496
+ {
497
+ method: 'POST',
498
+ path: '/api/market/checkout',
499
+ handler: 'market.createCheckout',
500
+ description: 'Create Stripe checkout session',
501
+ },
502
+ {
503
+ method: 'POST',
504
+ path: '/api/market/webhooks/stripe',
505
+ handler: 'market.stripeWebhook',
506
+ description: 'Stripe webhook for order fulfillment',
507
+ },
508
+ {
509
+ method: 'GET',
510
+ path: '/api/market/orders',
511
+ handler: 'market.listOrders',
512
+ middleware: ['auth', 'permission:market:orders'],
513
+ description: 'List all orders',
514
+ },
515
+ {
516
+ method: 'POST',
517
+ path: '/api/market/coupons/validate',
518
+ handler: 'market.validateCoupon',
519
+ description: 'Validate a coupon code (public)',
520
+ },
521
+ ],
522
+ pageElements: [
523
+ {
524
+ type: 'product-card',
525
+ name: 'Product Card',
526
+ category: 'commerce',
527
+ component: 'MarketProductCard',
528
+ settingsSchema: z.object({
529
+ productId: z.string().optional(),
530
+ showPrice: z.boolean().default(true),
531
+ showAddToCart: z.boolean().default(true),
532
+ showRating: z.boolean().default(false),
533
+ imageAspectRatio: z.enum(['square', 'portrait', 'landscape']).default('square'),
534
+ }),
535
+ },
536
+ {
537
+ type: 'product-grid',
538
+ name: 'Product Grid',
539
+ category: 'commerce',
540
+ component: 'MarketProductGrid',
541
+ settingsSchema: z.object({
542
+ category: z.string().optional(),
543
+ limit: z.number().min(1).max(24).default(8),
544
+ columns: z.number().min(1).max(6).default(4),
545
+ sortBy: z.enum(['price-asc', 'price-desc', 'newest', 'name', 'popular']).default('newest'),
546
+ showFilters: z.boolean().default(true),
547
+ }),
548
+ },
549
+ {
550
+ type: 'cart-widget',
551
+ name: 'Shopping Cart',
552
+ category: 'commerce',
553
+ component: 'MarketCartWidget',
554
+ settingsSchema: z.object({
555
+ style: z.enum(['mini', 'full', 'dropdown']).default('mini'),
556
+ showItemCount: z.boolean().default(true),
557
+ }),
558
+ },
559
+ {
560
+ type: 'checkout-button',
561
+ name: 'Checkout Button',
562
+ category: 'commerce',
563
+ component: 'MarketCheckoutButton',
564
+ settingsSchema: z.object({
565
+ text: z.string().default('Proceed to Checkout'),
566
+ style: z.enum(['primary', 'accent', 'outline']).default('primary'),
567
+ }),
568
+ },
569
+ ],
570
+ widgets: [
571
+ {
572
+ id: 'market-revenue',
573
+ name: 'Store Revenue',
574
+ component: 'MarketRevenueWidget',
575
+ areas: ['dashboard'],
576
+ defaultSize: { width: 4, height: 2 },
577
+ },
578
+ {
579
+ id: 'market-recent-orders',
580
+ name: 'Recent Orders',
581
+ component: 'MarketRecentOrdersWidget',
582
+ areas: ['dashboard', 'sidebar'],
583
+ defaultSize: { width: 4, height: 3 },
584
+ },
585
+ {
586
+ id: 'market-top-products',
587
+ name: 'Top Products',
588
+ component: 'MarketTopProductsWidget',
589
+ areas: ['dashboard'],
590
+ defaultSize: { width: 3, height: 3 },
591
+ },
592
+ ],
593
+ hooks: [
594
+ {
595
+ hook: 'content:before_save',
596
+ handler: (payload) => {
597
+ if (payload.content?.type === 'product') {
598
+ const product = payload.content;
599
+ // Generate slug from name
600
+ if (product.name && !product.slug) {
601
+ product.slug = product.name
602
+ .toLowerCase()
603
+ .replace(/[^a-z0-9]+/g, '-')
604
+ .replace(/^-|-$/g, '');
605
+ }
606
+ // Validate pricing
607
+ if (typeof product.price === 'number' && product.price < 0) {
608
+ throw new Error('Market: Product price cannot be negative');
609
+ }
610
+ // Calculate sale savings
611
+ if (product.salePrice && product.price) {
612
+ product.savingsPercent = Math.round(((product.price - product.salePrice) / product.price) * 100);
613
+ }
614
+ }
615
+ // Order validation
616
+ if (payload.content?.type === 'order') {
617
+ const order = payload.content;
618
+ if (!order.items || order.items.length === 0) {
619
+ throw new Error('Market: Order must have at least one item');
620
+ }
621
+ // Calculate order totals
622
+ let subtotal = 0;
623
+ for (const item of order.items) {
624
+ item.lineTotal = (item.price ?? 0) * (item.quantity ?? 1);
625
+ subtotal += item.lineTotal;
626
+ }
627
+ order.subtotal = subtotal;
628
+ order.discount = order._couponDiscount ?? 0;
629
+ order.shipping = order.shippingCost ?? 0;
630
+ order.tax = (subtotal - order.discount) * (order.taxRate ?? 0);
631
+ order.total = subtotal - order.discount + order.shipping + order.tax;
632
+ }
633
+ return payload;
634
+ },
635
+ priority: 5,
636
+ },
637
+ {
638
+ hook: 'content:after_save',
639
+ handler: (payload) => {
640
+ if (payload.content?.type === 'order' && payload.content?.status === 'paid') {
641
+ // Trigger order confirmation notification and stock deduction
642
+ }
643
+ },
644
+ priority: 10,
645
+ },
646
+ ],
647
+ settingsSchema: z.object({
648
+ currency: z.string().default('GBP'),
649
+ currencySymbol: z.string().default('£'),
650
+ taxRate: z.number().min(0).max(1).default(0.2),
651
+ taxInclusive: z.boolean().default(true),
652
+ enableCoupons: z.boolean().default(true),
653
+ enableShipping: z.boolean().default(false),
654
+ freeShippingThreshold: z.number().optional(),
655
+ stripePublishableKey: z.string().optional(),
656
+ stripeSecretKey: z.string().optional(),
657
+ stripeWebhookSecret: z.string().optional(),
658
+ lowStockThreshold: z.number().default(5),
659
+ enableReviews: z.boolean().default(false),
660
+ enableDigitalProducts: z.boolean().default(false),
661
+ }),
662
+ async onActivate(core) {
663
+ // Wire orders to invoicing via ledger
664
+ core.hooks.on('content:after_save', (payload) => {
665
+ if (payload.content?.type === 'order' && payload.content?.status === 'paid') {
666
+ if (core.plugins.isActive('@foundry/ledger')) {
667
+ core.hooks.emit('content:before_save', {
668
+ content: {
669
+ type: 'invoice',
670
+ action: 'generate_from_order',
671
+ orderId: payload.content.id,
672
+ customerEmail: payload.content.customerEmail,
673
+ lineItems: payload.content.items,
674
+ total: payload.content.total,
675
+ status: 'paid',
676
+ paidAt: new Date().toISOString(),
677
+ },
678
+ });
679
+ }
680
+ // Wire to inventory if depot is active
681
+ if (core.plugins.isActive('@foundry/depot')) {
682
+ for (const item of payload.content.items ?? []) {
683
+ if (item.inventoryItemId) {
684
+ core.hooks.emit('content:before_save', {
685
+ content: {
686
+ type: 'inventory_item',
687
+ id: item.inventoryItemId,
688
+ stockQuantity: -item.quantity,
689
+ _adjustment: true,
690
+ reason: `Order ${payload.content.id}`,
691
+ },
692
+ });
693
+ }
694
+ }
695
+ }
696
+ }
697
+ });
698
+ },
699
+ async onDeactivate(_core) { },
700
+ };
701
+ // ---------------------------------------------------------------------------
702
+ // Sentinel — Monitoring & Health Checks
703
+ // ---------------------------------------------------------------------------
704
+ export const sentinelPlugin = {
705
+ id: '@foundry/sentinel',
706
+ name: 'Sentinel',
707
+ version: '1.0.0',
708
+ description: 'Site monitoring with health checks, uptime tracking, error logging, and alert notifications',
709
+ adminPages: [
710
+ {
711
+ path: 'sentinel',
712
+ label: 'Monitoring',
713
+ icon: 'shield',
714
+ component: 'SentinelDashboardPage',
715
+ order: 50,
716
+ permission: 'sentinel:view',
717
+ children: [
718
+ {
719
+ path: 'health',
720
+ label: 'Health',
721
+ component: 'SentinelHealthPage',
722
+ permission: 'sentinel:view',
723
+ },
724
+ {
725
+ path: 'uptime',
726
+ label: 'Uptime',
727
+ component: 'SentinelUptimePage',
728
+ permission: 'sentinel:view',
729
+ },
730
+ {
731
+ path: 'errors',
732
+ label: 'Errors',
733
+ component: 'SentinelErrorsPage',
734
+ permission: 'sentinel:view',
735
+ },
736
+ {
737
+ path: 'alerts',
738
+ label: 'Alerts',
739
+ component: 'SentinelAlertsPage',
740
+ permission: 'sentinel:manage',
741
+ },
742
+ {
743
+ path: 'performance',
744
+ label: 'Performance',
745
+ component: 'SentinelPerformancePage',
746
+ permission: 'sentinel:view',
747
+ },
748
+ ],
749
+ },
750
+ ],
751
+ apiRoutes: [
752
+ {
753
+ method: 'GET',
754
+ path: '/api/sentinel/health',
755
+ handler: 'sentinel.healthCheck',
756
+ description: 'Run health check (public status endpoint)',
757
+ },
758
+ {
759
+ method: 'GET',
760
+ path: '/api/sentinel/status',
761
+ handler: 'sentinel.getStatus',
762
+ description: 'Get system status page (public)',
763
+ },
764
+ {
765
+ method: 'GET',
766
+ path: '/api/sentinel/errors',
767
+ handler: 'sentinel.listErrors',
768
+ middleware: ['auth', 'permission:sentinel:view'],
769
+ description: 'List recent errors',
770
+ },
771
+ {
772
+ method: 'GET',
773
+ path: '/api/sentinel/uptime',
774
+ handler: 'sentinel.getUptime',
775
+ middleware: ['auth'],
776
+ description: 'Get uptime history',
777
+ },
778
+ {
779
+ method: 'GET',
780
+ path: '/api/sentinel/performance',
781
+ handler: 'sentinel.getPerformance',
782
+ middleware: ['auth', 'permission:sentinel:view'],
783
+ description: 'Get performance metrics',
784
+ },
785
+ {
786
+ method: 'POST',
787
+ path: '/api/sentinel/alerts',
788
+ handler: 'sentinel.createAlert',
789
+ middleware: ['auth', 'permission:sentinel:manage'],
790
+ description: 'Create an alert rule',
791
+ },
792
+ {
793
+ method: 'POST',
794
+ path: '/api/sentinel/test-alert',
795
+ handler: 'sentinel.testAlert',
796
+ middleware: ['auth', 'permission:sentinel:manage'],
797
+ description: 'Send a test alert notification',
798
+ },
799
+ ],
800
+ widgets: [
801
+ {
802
+ id: 'sentinel-status',
803
+ name: 'System Status',
804
+ component: 'SentinelStatusWidget',
805
+ areas: ['dashboard'],
806
+ defaultSize: { width: 3, height: 2 },
807
+ },
808
+ {
809
+ id: 'sentinel-uptime',
810
+ name: 'Uptime',
811
+ component: 'SentinelUptimeWidget',
812
+ areas: ['dashboard'],
813
+ defaultSize: { width: 3, height: 2 },
814
+ },
815
+ {
816
+ id: 'sentinel-error-count',
817
+ name: 'Error Count',
818
+ component: 'SentinelErrorCountWidget',
819
+ areas: ['dashboard', 'sidebar'],
820
+ defaultSize: { width: 2, height: 1 },
821
+ },
822
+ {
823
+ id: 'sentinel-response-time',
824
+ name: 'Response Time',
825
+ component: 'SentinelResponseTimeWidget',
826
+ areas: ['dashboard'],
827
+ defaultSize: { width: 3, height: 2 },
828
+ },
829
+ ],
830
+ hooks: [
831
+ {
832
+ hook: 'page:after_render',
833
+ handler: (payload) => {
834
+ // Track render performance
835
+ const renderEnd = Date.now();
836
+ const renderStart = payload.page?._renderStartTime;
837
+ if (renderStart) {
838
+ const renderTime = renderEnd - renderStart;
839
+ _performanceLog.push({
840
+ pageId: payload.page?.id,
841
+ path: payload.page?.meta?.slug,
842
+ renderTimeMs: renderTime,
843
+ timestamp: new Date().toISOString(),
844
+ htmlSize: payload.html?.length ?? 0,
845
+ });
846
+ // Keep only last 1000 entries
847
+ if (_performanceLog.length > 1000) {
848
+ _performanceLog.splice(0, _performanceLog.length - 1000);
849
+ }
850
+ // Alert if render time exceeds threshold
851
+ if (renderTime > 3000) {
852
+ _alertQueue.push({
853
+ type: 'slow_render',
854
+ severity: 'warning',
855
+ message: `Page "${payload.page?.meta?.title ?? payload.page?.id}" took ${renderTime}ms to render`,
856
+ timestamp: new Date().toISOString(),
857
+ });
858
+ }
859
+ }
860
+ return payload;
861
+ },
862
+ priority: 999, // run absolutely last
863
+ },
864
+ {
865
+ hook: 'page:before_render',
866
+ handler: (payload) => {
867
+ // Stamp render start time for performance measurement
868
+ if (payload.page) {
869
+ payload.page._renderStartTime = Date.now();
870
+ }
871
+ return payload;
872
+ },
873
+ priority: 0, // run absolutely first
874
+ },
875
+ {
876
+ hook: 'config:changed',
877
+ handler: (payload) => {
878
+ // Log config changes for audit trail
879
+ _auditLog.push({
880
+ type: 'config_change',
881
+ key: payload.key,
882
+ timestamp: new Date().toISOString(),
883
+ });
884
+ if (_auditLog.length > 500) {
885
+ _auditLog.splice(0, _auditLog.length - 500);
886
+ }
887
+ },
888
+ priority: 5,
889
+ },
890
+ ],
891
+ settingsSchema: z.object({
892
+ healthCheckInterval: z.number().min(30).max(3600).default(300),
893
+ alertChannels: z.array(z.enum(['email', 'sms', 'webhook', 'in-app'])).default(['in-app', 'email']),
894
+ alertEmail: z.string().email().optional(),
895
+ alertWebhookUrl: z.string().url().optional(),
896
+ slowRenderThresholdMs: z.number().min(100).max(30000).default(3000),
897
+ errorRateAlertThreshold: z.number().min(1).max(100).default(5),
898
+ enablePerformanceTracking: z.boolean().default(true),
899
+ retentionDays: z.number().min(7).max(365).default(90),
900
+ publicStatusPage: z.boolean().default(false),
901
+ monitoredEndpoints: z.array(z.object({
902
+ url: z.string(),
903
+ name: z.string(),
904
+ interval: z.number().default(300),
905
+ expectedStatus: z.number().default(200),
906
+ })).default([]),
907
+ }),
908
+ async onActivate(_core) {
909
+ _performanceLog.length = 0;
910
+ _alertQueue.length = 0;
911
+ _auditLog.length = 0;
912
+ },
913
+ async onDeactivate(_core) {
914
+ _performanceLog.length = 0;
915
+ _alertQueue.length = 0;
916
+ _auditLog.length = 0;
917
+ },
918
+ };
919
+ const _performanceLog = [];
920
+ const _alertQueue = [];
921
+ const _auditLog = [];
922
+ //# sourceMappingURL=communication.js.map