From 4ff5800246514f0d46ae8fc83bf975da9e8c1b87 Mon Sep 17 00:00:00 2001 From: Lane Campbell Date: Thu, 9 Apr 2026 18:02:41 -0700 Subject: [PATCH 1/3] feat: plugin auto-discovery system and stripe admin enhancements Replace hardcoded plugin lists across 5+ files with a manifest-driven auto-discovery system. Enrich all 26 plugin manifests with standardized fields. Add Stripe subscription sync, webhook event logging, and tabbed admin UI. Plugin auto-discovery: - Rewrite generate-plugin-registry.mjs to scan manifest.json files and produce manifest-registry.ts as single source of truth - Replace hardcoded AVAILABLE_PLUGINS in admin-plugins.ts with registry - Replace CORE_PLUGINS in plugin-bootstrap.ts with registry - Replace MENU_PLUGINS in plugin-menu.ts with registry - Add icon resolution (text name -> Heroicon SVG) in plugin menu middleware - Enrich all 26 manifest.json files with codeName, iconEmoji, is_core, defaultSettings, and adminMenu fields Stripe plugin enhancements: - Add subscription sync from Stripe API (listAllSubscriptions + upsert) - Add stripe_events table and StripeEventService for webhook event logging - Webhook handler now logs all events as processed/failed/ignored - Add events log admin page with stats, type/status filters, pagination - Add publishable key field to settings page - Add shared tab bar (Subscriptions / Events / Settings) across all pages - Fix route registration order (stripe routes before catch-all) - Wrap admin pages in renderAdminLayoutCatalyst for proper styling Co-Authored-By: Claude Opus 4.6 --- package.json | 3 +- packages/core/src/app.ts | 14 +- packages/core/src/middleware/plugin-menu.ts | 71 +- .../plugins/available/easy-mdx/manifest.json | 29 +- .../available/magic-link-auth/manifest.json | 18 +- .../available/tinymce-plugin/manifest.json | 29 +- packages/core/src/plugins/cache/manifest.json | 16 +- .../ai-search-plugin/manifest.json | 38 +- .../core-plugins/analytics/manifest.json | 13 +- .../plugins/core-plugins/auth/manifest.json | 60 +- .../core-plugins/code-examples/manifest.json | 12 +- .../database-tools-plugin/manifest.json | 16 +- .../core-plugins/demo-login/manifest.json | 19 +- .../core-plugins/email-plugin/manifest.json | 16 +- .../global-variables-plugin/manifest.json | 15 +- .../hello-world-plugin/manifest.json | 11 +- .../plugins/core-plugins/media/manifest.json | 14 +- .../oauth-providers/manifest.json | 52 +- .../otp-login-plugin/manifest.json | 21 +- .../core-plugins/quill-editor/manifest.json | 29 +- .../security-audit-plugin/manifest.json | 102 ++- .../seed-data-plugin/manifest.json | 14 +- .../stripe-plugin/components/events-page.ts | 175 ++++ .../components/subscriptions-page.ts | 169 ++-- .../stripe-plugin/components/tab-bar.ts | 32 + .../core-plugins/stripe-plugin/manifest.json | 16 +- .../stripe-plugin/routes/admin.ts | 163 +++- .../core-plugins/stripe-plugin/routes/api.ts | 128 ++- .../stripe-plugin/services/stripe-api.ts | 32 + .../services/stripe-event-service.ts | 137 ++++ .../services/subscription-service.ts | 29 + .../core-plugins/stripe-plugin/types.ts | 33 + .../core-plugins/testimonials/manifest.json | 12 +- .../turnstile-plugin/manifest.json | 89 +- .../core-plugins/user-profiles/manifest.json | 7 +- .../workflow-plugin/manifest.json | 16 +- .../core/src/plugins/design/manifest.json | 20 +- .../core/src/plugins/manifest-registry.ts | 769 ++++++++++++++++++ .../plugins/redirect-management/manifest.json | 13 +- packages/core/src/routes/admin-plugins.ts | 583 ++----------- .../core/src/services/plugin-bootstrap.ts | 280 ++----- packages/scripts/generate-plugin-registry.mjs | 245 +++--- 42 files changed, 2519 insertions(+), 1041 deletions(-) create mode 100644 packages/core/src/plugins/core-plugins/stripe-plugin/components/events-page.ts create mode 100644 packages/core/src/plugins/core-plugins/stripe-plugin/components/tab-bar.ts create mode 100644 packages/core/src/plugins/core-plugins/stripe-plugin/services/stripe-event-service.ts create mode 100644 packages/core/src/plugins/manifest-registry.ts diff --git a/package.json b/package.json index 33c34d281..ce11b9e21 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,8 @@ "dev:www": "npm run dev --workspace=www", "build": "npm run build:core && npm run build --workspace=my-sonicjs-app", "build:www": "npm run build --workspace=www", - "build:core": "npm run build --workspace=@sonicjs-cms/core", + "plugins:generate": "node packages/scripts/generate-plugin-registry.mjs", + "build:core": "npm run plugins:generate && npm run build --workspace=@sonicjs-cms/core", "deploy": "npm run deploy --workspace=my-sonicjs-app", "deploy:www": "npm run deploy --workspace=www", "test": "npm run test --workspace=@sonicjs-cms/core", diff --git a/packages/core/src/app.ts b/packages/core/src/app.ts index be3167ecd..54a014a9c 100644 --- a/packages/core/src/app.ts +++ b/packages/core/src/app.ts @@ -254,6 +254,13 @@ export function createSonicJSApp(config: SonicJSConfig = {}): SonicJSApp { } } + // Plugin routes - Stripe (must be before /admin/plugins catch-all) + if (stripePlugin.routes && stripePlugin.routes.length > 0) { + for (const route of stripePlugin.routes) { + app.route(route.path, route.handler as any) + } + } + app.route('/admin/plugins', adminPluginRoutes) app.route('/admin/logs', adminLogsRoutes) app.route('/admin', adminUsersRoutes) @@ -262,13 +269,6 @@ export function createSonicJSApp(config: SonicJSConfig = {}): SonicJSApp { // Test cleanup routes (only for development/test environments) app.route('/', testCleanupRoutes) - // Plugin routes - Stripe (subscriptions, webhook, checkout) - if (stripePlugin.routes && stripePlugin.routes.length > 0) { - for (const route of stripePlugin.routes) { - app.route(route.path, route.handler as any) - } - } - // Plugin routes - Email if (emailPlugin.routes && emailPlugin.routes.length > 0) { for (const route of emailPlugin.routes) { diff --git a/packages/core/src/middleware/plugin-menu.ts b/packages/core/src/middleware/plugin-menu.ts index 130f6171f..52ab16c47 100644 --- a/packages/core/src/middleware/plugin-menu.ts +++ b/packages/core/src/middleware/plugin-menu.ts @@ -1,20 +1,48 @@ import type { Context, Next } from 'hono' import type { Bindings, Variables } from '../app' -import type { Plugin } from '../plugins/types' +import { PLUGIN_REGISTRY } from '../plugins/manifest-registry' -// All plugins that define menu items. The single source of truth for menu config -// is each plugin's addMenuItem() call β€” this array just lists which plugins to check. -import { securityAuditPlugin } from '../plugins/core-plugins/security-audit-plugin' +// Build menu plugin data from the auto-generated registry. +// Any plugin with an adminMenu entry in its manifest.json will +// automatically appear in the sidebar when active. +const REGISTRY_MENU_PLUGINS = Object.values(PLUGIN_REGISTRY) + .filter(p => p.adminMenu !== null) + .map(p => ({ + codeName: p.codeName, + label: p.adminMenu!.label, + path: p.adminMenu!.path, + icon: p.adminMenu!.icon, + order: p.adminMenu!.order, + })) -const MENU_PLUGINS: Plugin[] = [ - securityAuditPlugin, -] +// Map icon names from manifest.json to Heroicons SVG (outline, 24x24) +const ICON_SVG: Record = { + 'magnifying-glass': '', + 'chart-bar': '', + 'image': '', + 'palette': '', + 'envelope': '', + 'hand-raised': '', + 'key': '', + 'arrow-right': '', + 'shield-check': '', + 'credit-card': '', +} + +function resolveIcon(iconName?: string): string { + if (!iconName) return '' + // If it's already SVG markup, return as-is + if (iconName.startsWith('` + const resolvedIcon = resolveIcon(item.icon) || fallbackIcon return ` ${isActive ? '' : ''} @@ -28,7 +56,7 @@ function renderMenuItem(item: { label: string; path: string; icon?: string }, cu ${isActive ? 'data-current="true"' : ''} > - ${item.icon || fallbackIcon} + ${resolvedIcon} ${item.label} @@ -42,33 +70,38 @@ export function pluginMenuMiddleware() { return next() } - // Collect menu items from active plugins - let activeMenuItems: Array<{ label: string; path: string; icon?: string }> = [] + // Collect menu items from active plugins using the registry + let activeMenuItems: Array<{ label: string; path: string; icon?: string; order: number }> = [] try { const db = c.env.DB - const pluginNames = MENU_PLUGINS.map(p => p.name) - if (pluginNames.length > 0) { - const placeholders = pluginNames.map(() => '?').join(',') + const pluginCodeNames = REGISTRY_MENU_PLUGINS.map(p => p.codeName) + if (pluginCodeNames.length > 0) { + const placeholders = pluginCodeNames.map(() => '?').join(',') const result = await db.prepare( `SELECT name FROM plugins WHERE name IN (${placeholders}) AND status = 'active'` - ).bind(...pluginNames).all() + ).bind(...pluginCodeNames).all() const activeNames = new Set((result.results || []).map((r: any) => r.name)) - for (const plugin of MENU_PLUGINS) { - if (activeNames.has(plugin.name) && plugin.menuItems) { - activeMenuItems.push(...plugin.menuItems) + for (const plugin of REGISTRY_MENU_PLUGINS) { + if (activeNames.has(plugin.codeName)) { + activeMenuItems.push({ + label: plugin.label, + path: plugin.path, + icon: plugin.icon, + order: plugin.order, + }) } } // Sort by order - activeMenuItems.sort((a, b) => ((a as any).order || 0) - ((b as any).order || 0)) + activeMenuItems.sort((a, b) => a.order - b.order) } } catch { // DB not ready or plugin table doesn't exist yet } - c.set('pluginMenuItems', activeMenuItems.map(m => ({ label: m.label, path: m.path, icon: m.icon || '' }))) + c.set('pluginMenuItems', activeMenuItems.map(m => ({ label: m.label, path: m.path, icon: resolveIcon(m.icon) || '' }))) await next() diff --git a/packages/core/src/plugins/available/easy-mdx/manifest.json b/packages/core/src/plugins/available/easy-mdx/manifest.json index 9c61cc2d9..9ed7574db 100644 --- a/packages/core/src/plugins/available/easy-mdx/manifest.json +++ b/packages/core/src/plugins/available/easy-mdx/manifest.json @@ -8,7 +8,13 @@ "repository": "https://github.com/lane711/sonicjs", "license": "MIT", "category": "editor", - "tags": ["editor", "richtext", "markdown", "mdx", "content"], + "tags": [ + "editor", + "richtext", + "markdown", + "mdx", + "content" + ], "dependencies": [], "settings": { "defaultHeight": { @@ -22,14 +28,21 @@ "label": "Editor Theme", "description": "Visual theme for the editor", "default": "dark", - "options": ["light", "dark"] + "options": [ + "light", + "dark" + ] }, "toolbar": { "type": "select", "label": "Default Toolbar", "description": "Default toolbar configuration", "default": "full", - "options": ["full", "simple", "minimal"] + "options": [ + "full", + "simple", + "minimal" + ] }, "placeholder": { "type": "string", @@ -43,5 +56,13 @@ "onDeactivate": "deactivate" }, "routes": [], - "permissions": {} + "permissions": {}, + "iconEmoji": "πŸ“", + "is_core": false, + "defaultSettings": { + "defaultHeight": 400, + "theme": "dark", + "toolbar": "full", + "placeholder": "Start writing your content..." + } } diff --git a/packages/core/src/plugins/available/magic-link-auth/manifest.json b/packages/core/src/plugins/available/magic-link-auth/manifest.json index e586ab671..f5ffd0861 100644 --- a/packages/core/src/plugins/available/magic-link-auth/manifest.json +++ b/packages/core/src/plugins/available/magic-link-auth/manifest.json @@ -9,8 +9,15 @@ }, "category": "security", "icon": "πŸ”—", - "tags": ["authentication", "passwordless", "security", "email"], - "dependencies": ["email"], + "tags": [ + "authentication", + "passwordless", + "security", + "email" + ], + "dependencies": [ + "email" + ], "status": "inactive", "enabled": false, "license": "MIT", @@ -38,5 +45,12 @@ "default": false, "description": "Allow new users to register via magic link (requires valid email)" } + }, + "iconEmoji": "πŸ”—", + "is_core": false, + "defaultSettings": { + "linkExpiryMinutes": 15, + "rateLimitPerHour": 5, + "allowNewUsers": true } } diff --git a/packages/core/src/plugins/available/tinymce-plugin/manifest.json b/packages/core/src/plugins/available/tinymce-plugin/manifest.json index 76dd5e862..81fabfe6b 100644 --- a/packages/core/src/plugins/available/tinymce-plugin/manifest.json +++ b/packages/core/src/plugins/available/tinymce-plugin/manifest.json @@ -8,7 +8,13 @@ "repository": "https://github.com/lane711/sonicjs", "license": "MIT", "category": "editor", - "tags": ["editor", "richtext", "wysiwyg", "tinymce", "content"], + "tags": [ + "editor", + "richtext", + "wysiwyg", + "tinymce", + "content" + ], "dependencies": [], "settings": { "apiKey": { @@ -28,14 +34,21 @@ "label": "Default Toolbar", "description": "Default toolbar configuration", "default": "full", - "options": ["full", "simple", "minimal"] + "options": [ + "full", + "simple", + "minimal" + ] }, "skin": { "type": "select", "label": "Editor Skin", "description": "Visual theme for the editor", "default": "oxide-dark", - "options": ["oxide", "oxide-dark"] + "options": [ + "oxide", + "oxide-dark" + ] } }, "hooks": { @@ -43,5 +56,13 @@ "onDeactivate": "deactivate" }, "routes": [], - "permissions": {} + "permissions": {}, + "iconEmoji": "πŸ“", + "is_core": false, + "defaultSettings": { + "apiKey": "no-api-key", + "defaultHeight": 300, + "defaultToolbar": "full", + "skin": "oxide-dark" + } } diff --git a/packages/core/src/plugins/cache/manifest.json b/packages/core/src/plugins/cache/manifest.json index f3fc8db7b..43d311255 100644 --- a/packages/core/src/plugins/cache/manifest.json +++ b/packages/core/src/plugins/cache/manifest.json @@ -8,7 +8,13 @@ "repository": "https://github.com/lane711/sonicjs-ai/tree/main/src/plugins/cache", "license": "MIT", "category": "system", - "tags": ["cache", "performance", "optimization", "kv", "memory"], + "tags": [ + "cache", + "performance", + "optimization", + "kv", + "memory" + ], "dependencies": [], "settings": { "memoryEnabled": { @@ -65,5 +71,13 @@ "cache.view": "View cache statistics", "cache.clear": "Clear cache entries", "cache.invalidate": "Invalidate cache patterns" + }, + "iconEmoji": "⚑", + "is_core": true, + "defaultSettings": { + "enableMemoryCache": true, + "enableKVCache": true, + "enableDatabaseCache": true, + "defaultTTL": 3600 } } diff --git a/packages/core/src/plugins/core-plugins/ai-search-plugin/manifest.json b/packages/core/src/plugins/core-plugins/ai-search-plugin/manifest.json index 2cd4c5738..fda19e561 100644 --- a/packages/core/src/plugins/core-plugins/ai-search-plugin/manifest.json +++ b/packages/core/src/plugins/core-plugins/ai-search-plugin/manifest.json @@ -9,7 +9,11 @@ "homepage": "https://developers.cloudflare.com/ai-search/", "repository": "https://github.com/sonicjs/sonicjs", "license": "MIT", - "permissions": ["settings:write", "admin:access", "content:read"], + "permissions": [ + "settings:write", + "admin:access", + "content:read" + ], "dependencies": [], "configSchema": { "enabled": { @@ -29,7 +33,10 @@ "label": "AI Provider", "description": "Which AI service to use for semantic search", "default": "cloudflare", - "enum": ["cloudflare", "keyword-only"] + "enum": [ + "cloudflare", + "keyword-only" + ] }, "autocomplete_enabled": { "type": "boolean", @@ -60,11 +67,24 @@ "default": false } }, - "adminMenu": { - "label": "AI Search", - "icon": "magnifying-glass", - "href": "/admin/plugins/ai-search", - "parentId": "plugins", - "order": 50 - } + "adminMenu": { + "label": "AI Search", + "icon": "magnifying-glass", + "href": "/admin/plugins/ai-search", + "parentId": "plugins", + "order": 50 + }, + "codeName": "ai-search-plugin", + "iconEmoji": "πŸ”", + "is_core": true, + "defaultSettings": { + "enabled": true, + "ai_mode_enabled": true, + "selected_collections": [], + "dismissed_collections": [], + "autocomplete_enabled": true, + "cache_duration": 1, + "results_limit": 20, + "index_media": false + } } diff --git a/packages/core/src/plugins/core-plugins/analytics/manifest.json b/packages/core/src/plugins/core-plugins/analytics/manifest.json index cc89e1463..7cb71b0da 100644 --- a/packages/core/src/plugins/core-plugins/analytics/manifest.json +++ b/packages/core/src/plugins/core-plugins/analytics/manifest.json @@ -8,7 +8,13 @@ "repository": "https://github.com/lane711/sonicjs-ai/tree/main/src/plugins/core-plugins", "license": "MIT", "category": "seo", - "tags": ["analytics", "metrics", "tracking", "insights", "dashboard"], + "tags": [ + "analytics", + "metrics", + "tracking", + "insights", + "dashboard" + ], "dependencies": [], "settings": { "trackPageViews": { @@ -44,5 +50,8 @@ "icon": "chart-bar", "path": "/admin/analytics", "order": 50 - } + }, + "iconEmoji": "πŸ“Š", + "is_core": true, + "defaultSettings": {} } diff --git a/packages/core/src/plugins/core-plugins/auth/manifest.json b/packages/core/src/plugins/core-plugins/auth/manifest.json index abd90872e..e9b9fd884 100644 --- a/packages/core/src/plugins/core-plugins/auth/manifest.json +++ b/packages/core/src/plugins/core-plugins/auth/manifest.json @@ -8,7 +8,14 @@ "repository": "https://github.com/lane711/sonicjs-ai/tree/main/src/plugins/core-plugins", "license": "MIT", "category": "security", - "tags": ["auth", "authentication", "users", "security", "rbac", "sessions"], + "tags": [ + "auth", + "authentication", + "users", + "security", + "rbac", + "sessions" + ], "dependencies": [], "settings": { "sessionTimeout": { @@ -39,5 +46,56 @@ "manage:users": "Manage users and accounts", "manage:roles": "Manage user roles", "manage:permissions": "Manage permission settings" + }, + "iconEmoji": "πŸ”", + "is_core": true, + "defaultSettings": { + "requiredFields": { + "email": { + "required": true, + "minLength": 5, + "label": "Email", + "type": "email" + }, + "password": { + "required": true, + "minLength": 8, + "label": "Password", + "type": "password" + }, + "username": { + "required": true, + "minLength": 3, + "label": "Username", + "type": "text" + }, + "firstName": { + "required": true, + "minLength": 1, + "label": "First Name", + "type": "text" + }, + "lastName": { + "required": true, + "minLength": 1, + "label": "Last Name", + "type": "text" + } + }, + "validation": { + "emailFormat": true, + "allowDuplicateUsernames": false, + "passwordRequirements": { + "requireUppercase": false, + "requireLowercase": false, + "requireNumbers": false, + "requireSpecialChars": false + } + }, + "registration": { + "enabled": true, + "requireEmailVerification": false, + "defaultRole": "viewer" + } } } diff --git a/packages/core/src/plugins/core-plugins/code-examples/manifest.json b/packages/core/src/plugins/core-plugins/code-examples/manifest.json index 6379095ef..9892cc474 100644 --- a/packages/core/src/plugins/core-plugins/code-examples/manifest.json +++ b/packages/core/src/plugins/core-plugins/code-examples/manifest.json @@ -8,7 +8,12 @@ "repository": "https://github.com/lane711/sonicjs-ai/tree/main/src/plugins/core-plugins", "license": "MIT", "category": "content", - "tags": ["code", "examples", "snippets", "documentation"], + "tags": [ + "code", + "examples", + "snippets", + "documentation" + ], "dependencies": [], "settings": {}, "hooks": { @@ -18,5 +23,8 @@ "routes": [], "permissions": { "code-examples:manage": "Manage code examples" - } + }, + "iconEmoji": "πŸ’»", + "is_core": false, + "defaultSettings": {} } diff --git a/packages/core/src/plugins/core-plugins/database-tools-plugin/manifest.json b/packages/core/src/plugins/core-plugins/database-tools-plugin/manifest.json index ecb45a017..2263211c8 100644 --- a/packages/core/src/plugins/core-plugins/database-tools-plugin/manifest.json +++ b/packages/core/src/plugins/core-plugins/database-tools-plugin/manifest.json @@ -8,7 +8,13 @@ "repository": "https://github.com/lane711/sonicjs-ai/tree/main/src/plugins/core-plugins/database-tools-plugin", "license": "MIT", "category": "development", - "tags": ["database", "admin", "tools", "migrations", "backup"], + "tags": [ + "database", + "admin", + "tools", + "migrations", + "backup" + ], "dependencies": [], "settings": {}, "hooks": { @@ -18,5 +24,13 @@ "routes": [], "permissions": { "database:admin": "Administer database" + }, + "iconEmoji": "πŸ—„οΈ", + "is_core": false, + "defaultSettings": { + "enableTruncate": true, + "enableBackup": true, + "enableValidation": true, + "requireConfirmation": true } } diff --git a/packages/core/src/plugins/core-plugins/demo-login/manifest.json b/packages/core/src/plugins/core-plugins/demo-login/manifest.json index f117b7382..f239de5fc 100644 --- a/packages/core/src/plugins/core-plugins/demo-login/manifest.json +++ b/packages/core/src/plugins/core-plugins/demo-login/manifest.json @@ -8,13 +8,26 @@ "repository": "https://github.com/lane711/sonicjs-ai/tree/main/src/plugins/core-plugins", "license": "MIT", "category": "utilities", - "tags": ["demo", "testing", "development"], - "dependencies": ["core-auth"], + "tags": [ + "demo", + "testing", + "development" + ], + "dependencies": [ + "core-auth" + ], "settings": {}, "hooks": { "onActivate": "activate", "onDeactivate": "deactivate" }, "routes": [], - "permissions": {} + "permissions": {}, + "iconEmoji": "🎯", + "is_core": false, + "defaultSettings": { + "enableNotice": true, + "demoEmail": "admin@sonicjs.com", + "demoPassword": "sonicjs!" + } } diff --git a/packages/core/src/plugins/core-plugins/email-plugin/manifest.json b/packages/core/src/plugins/core-plugins/email-plugin/manifest.json index e3e2fd146..d88bbfc1a 100644 --- a/packages/core/src/plugins/core-plugins/email-plugin/manifest.json +++ b/packages/core/src/plugins/core-plugins/email-plugin/manifest.json @@ -8,7 +8,12 @@ "repository": "https://github.com/lane711/sonicjs-ai/tree/main/src/plugins/core-plugins/email-plugin", "license": "MIT", "category": "utilities", - "tags": ["email", "resend", "transactional", "notifications"], + "tags": [ + "email", + "resend", + "transactional", + "notifications" + ], "dependencies": [], "settings": { "apiKey": { @@ -53,5 +58,14 @@ "icon": "envelope", "path": "/admin/plugins/email/settings", "order": 80 + }, + "iconEmoji": "πŸ“§", + "is_core": false, + "defaultSettings": { + "apiKey": "", + "fromEmail": "", + "fromName": "", + "replyTo": "", + "logoUrl": "" } } diff --git a/packages/core/src/plugins/core-plugins/global-variables-plugin/manifest.json b/packages/core/src/plugins/core-plugins/global-variables-plugin/manifest.json index 23311a3a1..b969601e6 100644 --- a/packages/core/src/plugins/core-plugins/global-variables-plugin/manifest.json +++ b/packages/core/src/plugins/core-plugins/global-variables-plugin/manifest.json @@ -8,7 +8,13 @@ "repository": "https://github.com/SonicJs-Org/sonicjs", "license": "MIT", "category": "content", - "tags": ["variables", "tokens", "dynamic-content", "rich-text", "globals"], + "tags": [ + "variables", + "tokens", + "dynamic-content", + "rich-text", + "globals" + ], "dependencies": [], "settings": { "enableResolution": true, @@ -23,5 +29,12 @@ "permissions": { "global-variables:manage": "Manage global variables", "global-variables:view": "View global variables" + }, + "iconEmoji": "πŸ”€", + "is_core": false, + "defaultSettings": { + "enableResolution": true, + "cacheEnabled": true, + "cacheTTL": 300 } } diff --git a/packages/core/src/plugins/core-plugins/hello-world-plugin/manifest.json b/packages/core/src/plugins/core-plugins/hello-world-plugin/manifest.json index 04ecdacad..2ed46872f 100644 --- a/packages/core/src/plugins/core-plugins/hello-world-plugin/manifest.json +++ b/packages/core/src/plugins/core-plugins/hello-world-plugin/manifest.json @@ -8,7 +8,11 @@ "repository": "https://github.com/lane711/sonicjs-ai/tree/main/src/plugins/core-plugins/hello-world-plugin", "license": "MIT", "category": "utilities", - "tags": ["demo", "example", "hello-world"], + "tags": [ + "demo", + "example", + "hello-world" + ], "dependencies": [], "settings": {}, "hooks": { @@ -24,5 +28,8 @@ "icon": "hand-raised", "path": "/admin/hello-world", "order": 90 - } + }, + "iconEmoji": "πŸ‘‹", + "is_core": false, + "defaultSettings": {} } diff --git a/packages/core/src/plugins/core-plugins/media/manifest.json b/packages/core/src/plugins/core-plugins/media/manifest.json index 936a53fae..5c93c4d54 100644 --- a/packages/core/src/plugins/core-plugins/media/manifest.json +++ b/packages/core/src/plugins/core-plugins/media/manifest.json @@ -8,7 +8,14 @@ "repository": "https://github.com/lane711/sonicjs-ai/tree/main/src/plugins/core-plugins", "license": "MIT", "category": "media", - "tags": ["media", "uploads", "images", "files", "storage", "r2"], + "tags": [ + "media", + "uploads", + "images", + "files", + "storage", + "r2" + ], "dependencies": [], "settings": { "maxFileSize": { @@ -50,5 +57,8 @@ "icon": "image", "path": "/admin/media", "order": 30 - } + }, + "iconEmoji": "πŸ“Έ", + "is_core": true, + "defaultSettings": {} } diff --git a/packages/core/src/plugins/core-plugins/oauth-providers/manifest.json b/packages/core/src/plugins/core-plugins/oauth-providers/manifest.json index 3a9bb1808..68c156918 100644 --- a/packages/core/src/plugins/core-plugins/oauth-providers/manifest.json +++ b/packages/core/src/plugins/core-plugins/oauth-providers/manifest.json @@ -9,7 +9,6 @@ }, "category": "authentication", "icon": "shield", - "isCore": true, "dependencies": [], "settings": { "providers": { @@ -19,20 +18,59 @@ "github": { "type": "object", "properties": { - "clientId": { "type": "string", "title": "GitHub Client ID" }, - "clientSecret": { "type": "string", "title": "GitHub Client Secret", "format": "password" }, - "enabled": { "type": "boolean", "title": "Enable GitHub Login", "default": false } + "clientId": { + "type": "string", + "title": "GitHub Client ID" + }, + "clientSecret": { + "type": "string", + "title": "GitHub Client Secret", + "format": "password" + }, + "enabled": { + "type": "boolean", + "title": "Enable GitHub Login", + "default": false + } } }, "google": { "type": "object", "properties": { - "clientId": { "type": "string", "title": "Google Client ID" }, - "clientSecret": { "type": "string", "title": "Google Client Secret", "format": "password" }, - "enabled": { "type": "boolean", "title": "Enable Google Login", "default": false } + "clientId": { + "type": "string", + "title": "Google Client ID" + }, + "clientSecret": { + "type": "string", + "title": "Google Client Secret", + "format": "password" + }, + "enabled": { + "type": "boolean", + "title": "Enable Google Login", + "default": false + } } } } } + }, + "id": "oauth-providers", + "iconEmoji": "πŸ”‘", + "is_core": true, + "defaultSettings": { + "providers": { + "github": { + "clientId": "", + "clientSecret": "", + "enabled": false + }, + "google": { + "clientId": "", + "clientSecret": "", + "enabled": false + } + } } } diff --git a/packages/core/src/plugins/core-plugins/otp-login-plugin/manifest.json b/packages/core/src/plugins/core-plugins/otp-login-plugin/manifest.json index 66f536e50..0b71276cd 100644 --- a/packages/core/src/plugins/core-plugins/otp-login-plugin/manifest.json +++ b/packages/core/src/plugins/core-plugins/otp-login-plugin/manifest.json @@ -8,8 +8,16 @@ "repository": "https://github.com/lane711/sonicjs/tree/main/src/plugins/core-plugins/otp-login-plugin", "license": "MIT", "category": "security", - "tags": ["otp", "passwordless", "authentication", "email", "2fa"], - "dependencies": ["email"], + "tags": [ + "otp", + "passwordless", + "authentication", + "email", + "2fa" + ], + "dependencies": [ + "email" + ], "settings": { "codeLength": { "type": "number", @@ -70,5 +78,14 @@ "icon": "key", "path": "/admin/plugins/otp-login/settings", "order": 85 + }, + "iconEmoji": "πŸ”’", + "is_core": false, + "defaultSettings": { + "codeLength": 6, + "codeExpiryMinutes": 10, + "maxAttempts": 3, + "rateLimitPerHour": 5, + "allowNewUserRegistration": false } } diff --git a/packages/core/src/plugins/core-plugins/quill-editor/manifest.json b/packages/core/src/plugins/core-plugins/quill-editor/manifest.json index 0bec7020f..a5281e6ae 100644 --- a/packages/core/src/plugins/core-plugins/quill-editor/manifest.json +++ b/packages/core/src/plugins/core-plugins/quill-editor/manifest.json @@ -8,7 +8,13 @@ "repository": "https://github.com/lane711/sonicjs", "license": "MIT", "category": "editor", - "tags": ["editor", "richtext", "wysiwyg", "quill", "content"], + "tags": [ + "editor", + "richtext", + "wysiwyg", + "quill", + "content" + ], "dependencies": [], "settings": { "version": { @@ -28,14 +34,21 @@ "label": "Default Toolbar", "description": "Default toolbar configuration", "default": "full", - "options": ["full", "simple", "minimal"] + "options": [ + "full", + "simple", + "minimal" + ] }, "theme": { "type": "select", "label": "Editor Theme", "description": "Visual theme for the editor", "default": "snow", - "options": ["snow", "bubble"] + "options": [ + "snow", + "bubble" + ] } }, "hooks": { @@ -43,5 +56,13 @@ "onDeactivate": "deactivate" }, "routes": [], - "permissions": {} + "permissions": {}, + "iconEmoji": "✍️", + "is_core": true, + "defaultSettings": { + "version": "2.0.2", + "defaultHeight": 300, + "defaultToolbar": "full", + "theme": "snow" + } } diff --git a/packages/core/src/plugins/core-plugins/security-audit-plugin/manifest.json b/packages/core/src/plugins/core-plugins/security-audit-plugin/manifest.json index 807c0494d..ac5548cf4 100644 --- a/packages/core/src/plugins/core-plugins/security-audit-plugin/manifest.json +++ b/packages/core/src/plugins/core-plugins/security-audit-plugin/manifest.json @@ -5,36 +5,84 @@ "description": "Security event logging, brute-force detection, and analytics dashboard. Monitors login attempts, registrations, lockouts, and suspicious activity.", "author": "SonicJS Team", "category": "security", - "tags": ["security", "audit", "brute-force", "analytics", "login-monitoring"], + "tags": [ + "security", + "audit", + "brute-force", + "analytics", + "login-monitoring" + ], "dependencies": [], "settings": { "retention": { "type": "object", "properties": { - "daysToKeep": { "type": "number", "default": 90 }, - "maxEvents": { "type": "number", "default": 100000 }, - "autoPurge": { "type": "boolean", "default": true } + "daysToKeep": { + "type": "number", + "default": 90 + }, + "maxEvents": { + "type": "number", + "default": 100000 + }, + "autoPurge": { + "type": "boolean", + "default": true + } } }, "bruteForce": { "type": "object", "properties": { - "enabled": { "type": "boolean", "default": true }, - "maxFailedAttemptsPerIP": { "type": "number", "default": 10 }, - "maxFailedAttemptsPerEmail": { "type": "number", "default": 5 }, - "windowMinutes": { "type": "number", "default": 15 }, - "lockoutDurationMinutes": { "type": "number", "default": 30 }, - "alertThreshold": { "type": "number", "default": 20 } + "enabled": { + "type": "boolean", + "default": true + }, + "maxFailedAttemptsPerIP": { + "type": "number", + "default": 10 + }, + "maxFailedAttemptsPerEmail": { + "type": "number", + "default": 5 + }, + "windowMinutes": { + "type": "number", + "default": 15 + }, + "lockoutDurationMinutes": { + "type": "number", + "default": 30 + }, + "alertThreshold": { + "type": "number", + "default": 20 + } } }, "logging": { "type": "object", "properties": { - "logSuccessfulLogins": { "type": "boolean", "default": true }, - "logLogouts": { "type": "boolean", "default": true }, - "logRegistrations": { "type": "boolean", "default": true }, - "logPasswordResets": { "type": "boolean", "default": true }, - "logPermissionDenied": { "type": "boolean", "default": true } + "logSuccessfulLogins": { + "type": "boolean", + "default": true + }, + "logLogouts": { + "type": "boolean", + "default": true + }, + "logRegistrations": { + "type": "boolean", + "default": true + }, + "logPasswordResets": { + "type": "boolean", + "default": true + }, + "logPermissionDenied": { + "type": "boolean", + "default": true + } } } }, @@ -47,5 +95,29 @@ "icon": "shield-check", "path": "/admin/plugins/security-audit", "order": 85 + }, + "iconEmoji": "πŸ›‘οΈ", + "is_core": false, + "defaultSettings": { + "retention": { + "daysToKeep": 90, + "maxEvents": 100000, + "autoPurge": true + }, + "bruteForce": { + "enabled": true, + "maxFailedAttemptsPerIP": 10, + "maxFailedAttemptsPerEmail": 5, + "windowMinutes": 15, + "lockoutDurationMinutes": 30, + "alertThreshold": 20 + }, + "logging": { + "logSuccessfulLogins": true, + "logLogouts": true, + "logRegistrations": true, + "logPasswordResets": true, + "logPermissionDenied": true + } } } diff --git a/packages/core/src/plugins/core-plugins/seed-data-plugin/manifest.json b/packages/core/src/plugins/core-plugins/seed-data-plugin/manifest.json index 29b0e99b7..ab5e944c7 100644 --- a/packages/core/src/plugins/core-plugins/seed-data-plugin/manifest.json +++ b/packages/core/src/plugins/core-plugins/seed-data-plugin/manifest.json @@ -8,7 +8,12 @@ "repository": "https://github.com/lane711/sonicjs-ai/tree/main/src/plugins/core-plugins/seed-data-plugin", "license": "MIT", "category": "development", - "tags": ["development", "testing", "seed-data", "demo"], + "tags": [ + "development", + "testing", + "seed-data", + "demo" + ], "dependencies": [], "settings": {}, "hooks": { @@ -18,5 +23,12 @@ "routes": [], "permissions": { "seed-data:generate": "Generate seed data" + }, + "iconEmoji": "🌱", + "is_core": false, + "defaultSettings": { + "userCount": 20, + "contentCount": 200, + "defaultPassword": "password123" } } diff --git a/packages/core/src/plugins/core-plugins/stripe-plugin/components/events-page.ts b/packages/core/src/plugins/core-plugins/stripe-plugin/components/events-page.ts new file mode 100644 index 000000000..30421290d --- /dev/null +++ b/packages/core/src/plugins/core-plugins/stripe-plugin/components/events-page.ts @@ -0,0 +1,175 @@ +import type { StripeEventRecord, StripeEventStats } from '../types' +import { renderAdminLayoutCatalyst, type AdminLayoutCatalystData } from '../../../../templates/layouts/admin-layout-catalyst.template' +import { renderStripeTabBar } from './tab-bar' + +export interface EventsPageData { + events: StripeEventRecord[] + stats: StripeEventStats + types: string[] + filters: { type?: string; status?: string; page: number; totalPages: number } + user?: { name: string; email: string; role: string } + version?: string + dynamicMenuItems?: Array<{ label: string; path: string; icon: string }> +} + +export function renderEventsPage(data: EventsPageData): string { + const { events, stats, types, filters, user, version, dynamicMenuItems } = data + + const content = ` +
+
+
+

Stripe

+

+ Webhook event log showing all processed, failed, and ignored Stripe events. +

+
+
+ + ${renderStripeTabBar('/admin/plugins/stripe/events')} + + +
+ ${eventStatsCard('Total Events', stats.total, 'text-zinc-950 dark:text-white')} + ${eventStatsCard('Processed', stats.processed, 'text-emerald-600 dark:text-emerald-400')} + ${eventStatsCard('Failed', stats.failed, 'text-red-600 dark:text-red-400')} + ${eventStatsCard('Ignored', stats.ignored, 'text-zinc-500 dark:text-zinc-400')} +
+ + +
+
+ + + + + +
+
+ + +
+ + + + + + + + + + + + ${events.length === 0 + ? '' + : events.map(renderEventRow).join('') + } + +
TimeTypeObjectStatusEvent ID
No events recorded yet
+ + ${renderEventPagination(filters.page, filters.totalPages, filters.type, filters.status)} +
+
+ ` + + const layoutData: AdminLayoutCatalystData = { + title: 'Stripe Events', + pageTitle: 'Stripe Events', + currentPath: '/admin/plugins/stripe', + user, + content, + version, + dynamicMenuItems + } + + return renderAdminLayoutCatalyst(layoutData) +} + +function eventStatsCard(label: string, value: number, colorClass: string): string { + return ` +
+

${label}

+

${value}

+
+ ` +} + +function eventStatusOption(value: string, current?: string): string { + const selected = value === current ? 'selected' : '' + const label = value.charAt(0).toUpperCase() + value.slice(1) + return `` +} + +function eventStatusBadge(status: string): string { + const colors: Record = { + processed: 'bg-emerald-400/10 text-emerald-500 dark:text-emerald-400 ring-emerald-400/20', + failed: 'bg-red-400/10 text-red-500 dark:text-red-400 ring-red-400/20', + ignored: 'bg-zinc-400/10 text-zinc-500 dark:text-zinc-400 ring-zinc-400/20' + } + const color = colors[status] || 'bg-zinc-400/10 text-zinc-500 ring-zinc-400/20' + return `${status}` +} + +function formatTimestamp(timestamp: number): string { + if (!timestamp) return '-' + const d = new Date(timestamp * 1000) + return d.toLocaleString('en-US', { + month: 'short', day: 'numeric', year: 'numeric', + hour: '2-digit', minute: '2-digit', second: '2-digit' + }) +} + +function renderEventRow(event: StripeEventRecord): string { + const errorTooltip = event.error ? ` title="${event.error.replace(/"/g, '"')}"` : '' + return ` + + + ${formatTimestamp(event.processedAt)} + + + ${event.type} + + +
${event.objectId || '-'}
+
${event.objectType}
+ + ${eventStatusBadge(event.status)} + ${event.stripeEventId} + + ` +} + +function renderEventPagination(page: number, totalPages: number, type?: string, status?: string): string { + if (totalPages <= 1) return '' + + const params: string[] = [] + if (type) params.push(`type=${type}`) + if (status) params.push(`status=${status}`) + const extra = params.length > 0 ? `&${params.join('&')}` : '' + + return ` +
+
+ Page ${page} of ${totalPages} +
+
+ ${page > 1 + ? `Previous` + : '' + } + ${page < totalPages + ? `Next` + : '' + } +
+
+ ` +} diff --git a/packages/core/src/plugins/core-plugins/stripe-plugin/components/subscriptions-page.ts b/packages/core/src/plugins/core-plugins/stripe-plugin/components/subscriptions-page.ts index 2e490b56c..d268918ef 100644 --- a/packages/core/src/plugins/core-plugins/stripe-plugin/components/subscriptions-page.ts +++ b/packages/core/src/plugins/core-plugins/stripe-plugin/components/subscriptions-page.ts @@ -1,26 +1,52 @@ import type { Subscription, SubscriptionStats, SubscriptionStatus } from '../types' +import { renderAdminLayoutCatalyst, type AdminLayoutCatalystData } from '../../../../templates/layouts/admin-layout-catalyst.template' +import { renderStripeTabBar } from './tab-bar' -export function renderSubscriptionsPage( - subscriptions: (Subscription & { userEmail?: string })[], - stats: SubscriptionStats, +export interface StripePageData { + subscriptions: (Subscription & { userEmail?: string })[] + stats: SubscriptionStats filters: { status?: string; page: number; totalPages: number } -): string { - return ` -
+ user?: { name: string; email: string; role: string } + version?: string + dynamicMenuItems?: Array<{ label: string; path: string; icon: string }> +} + +export function renderSubscriptionsPage(data: StripePageData): string { + const { subscriptions, stats, filters, user, version, dynamicMenuItems } = data + + const content = ` +
+
+
+

Stripe

+

+ Manage subscriptions, view billing status, and monitor payment events. +

+
+
+ +
+
+ + ${renderStripeTabBar('/admin/plugins/stripe')} + -
- ${statsCard('Total', stats.total, 'text-gray-700')} - ${statsCard('Active', stats.active, 'text-green-600')} - ${statsCard('Trialing', stats.trialing, 'text-blue-600')} - ${statsCard('Past Due', stats.pastDue, 'text-yellow-600')} - ${statsCard('Canceled', stats.canceled, 'text-red-600')} +
+ ${statsCard('Total', stats.total, 'text-zinc-950 dark:text-white')} + ${statsCard('Active', stats.active, 'text-emerald-600 dark:text-emerald-400')} + ${statsCard('Trialing', stats.trialing, 'text-blue-600 dark:text-blue-400')} + ${statsCard('Past Due', stats.pastDue, 'text-amber-600 dark:text-amber-400')} + ${statsCard('Canceled', stats.canceled, 'text-red-600 dark:text-red-400')}
-
+
- - ${statusOption('active', filters.status)} ${statusOption('trialing', filters.status)} @@ -33,21 +59,21 @@ export function renderSubscriptionsPage(
-
- - +
+
+ - - - - - - + + + + + + - + ${subscriptions.length === 0 - ? '' + ? '' : subscriptions.map(renderRow).join('') } @@ -55,15 +81,56 @@ export function renderSubscriptionsPage( ${renderPagination(filters.page, filters.totalPages, filters.status)} + + + + ` + + const layoutData: AdminLayoutCatalystData = { + title: 'Stripe Subscriptions', + pageTitle: 'Stripe Subscriptions', + currentPath: '/admin/plugins/stripe', + user, + content, + version, + dynamicMenuItems + } + + return renderAdminLayoutCatalyst(layoutData) } function statsCard(label: string, value: number, colorClass: string): string { return ` -
-
${label}
-
${value}
+
+

${label}

+

${value}

` } @@ -76,18 +143,18 @@ function statusOption(value: string, current?: string): string { function statusBadge(status: SubscriptionStatus): string { const colors: Record = { - active: 'bg-green-100 text-green-800', - trialing: 'bg-blue-100 text-blue-800', - past_due: 'bg-yellow-100 text-yellow-800', - canceled: 'bg-red-100 text-red-800', - unpaid: 'bg-orange-100 text-orange-800', - paused: 'bg-gray-100 text-gray-800', - incomplete: 'bg-gray-100 text-gray-500', - incomplete_expired: 'bg-red-100 text-red-500' + active: 'bg-emerald-400/10 text-emerald-500 dark:text-emerald-400 ring-emerald-400/20', + trialing: 'bg-blue-400/10 text-blue-500 dark:text-blue-400 ring-blue-400/20', + past_due: 'bg-amber-400/10 text-amber-500 dark:text-amber-400 ring-amber-400/20', + canceled: 'bg-red-400/10 text-red-500 dark:text-red-400 ring-red-400/20', + unpaid: 'bg-orange-400/10 text-orange-500 dark:text-orange-400 ring-orange-400/20', + paused: 'bg-zinc-400/10 text-zinc-500 dark:text-zinc-400 ring-zinc-400/20', + incomplete: 'bg-zinc-400/10 text-zinc-500 dark:text-zinc-400 ring-zinc-400/20', + incomplete_expired: 'bg-red-400/10 text-red-500 dark:text-red-400 ring-red-400/20' } - const color = colors[status] || 'bg-gray-100 text-gray-800' + const color = colors[status] || 'bg-zinc-400/10 text-zinc-500 ring-zinc-400/20' const label = status.replace('_', ' ') - return `${label}` + return `${label}` } function formatDate(timestamp: number): string { @@ -101,26 +168,26 @@ function formatDate(timestamp: number): string { function renderRow(sub: Subscription & { userEmail?: string }): string { return ` -
+ - - + @@ -133,17 +200,17 @@ function renderPagination(page: number, totalPages: number, status?: string): st const params = status ? `&status=${status}` : '' return ` -
-
+
+
Page ${page} of ${totalPages}
${page > 1 - ? `Previous` + ? `Previous` : '' } ${page < totalPages - ? `Next` + ? `Next` : '' }
diff --git a/packages/core/src/plugins/core-plugins/stripe-plugin/components/tab-bar.ts b/packages/core/src/plugins/core-plugins/stripe-plugin/components/tab-bar.ts new file mode 100644 index 000000000..d6bde5c7e --- /dev/null +++ b/packages/core/src/plugins/core-plugins/stripe-plugin/components/tab-bar.ts @@ -0,0 +1,32 @@ +/** + * Shared tab bar for all Stripe admin pages. + */ + +const TABS = [ + { label: 'Subscriptions', path: '/admin/plugins/stripe' }, + { label: 'Events', path: '/admin/plugins/stripe/events' }, + { label: 'Settings', path: '/admin/plugins/stripe/settings' }, +] + +export function renderStripeTabBar(currentPath: string): string { + const tabs = TABS.map(tab => { + const isActive = currentPath === tab.path + || (tab.path === '/admin/plugins/stripe' && currentPath === '/admin/plugins/stripe/') + return ` + + ${tab.label} + ` + }).join('') + + return ` +
+ +
+ ` +} diff --git a/packages/core/src/plugins/core-plugins/stripe-plugin/manifest.json b/packages/core/src/plugins/core-plugins/stripe-plugin/manifest.json index 26b71b56e..9c59ce438 100644 --- a/packages/core/src/plugins/core-plugins/stripe-plugin/manifest.json +++ b/packages/core/src/plugins/core-plugins/stripe-plugin/manifest.json @@ -6,7 +6,6 @@ "author": "SonicJS Team", "category": "payments", "icon": "credit-card", - "core": true, "dependencies": [], "settings": { "schema": { @@ -52,5 +51,20 @@ "stripe:subscription.deleted": "Fired when a subscription is cancelled/deleted", "stripe:payment.succeeded": "Fired when a payment succeeds", "stripe:payment.failed": "Fired when a payment fails" + }, + "iconEmoji": "πŸ’³", + "is_core": true, + "defaultSettings": { + "stripeSecretKey": "", + "stripeWebhookSecret": "", + "stripePriceId": "", + "successUrl": "/admin/dashboard", + "cancelUrl": "/admin/dashboard" + }, + "adminMenu": { + "label": "Stripe", + "icon": "credit-card", + "path": "/admin/plugins/stripe", + "order": 90 } } diff --git a/packages/core/src/plugins/core-plugins/stripe-plugin/routes/admin.ts b/packages/core/src/plugins/core-plugins/stripe-plugin/routes/admin.ts index bf0874488..f9ab5b9b4 100644 --- a/packages/core/src/plugins/core-plugins/stripe-plugin/routes/admin.ts +++ b/packages/core/src/plugins/core-plugins/stripe-plugin/routes/admin.ts @@ -2,7 +2,10 @@ import { Hono } from 'hono' import { requireAuth } from '../../../../middleware' import { PluginService } from '../../../../services' import { SubscriptionService } from '../services/subscription-service' +import { StripeEventService } from '../services/stripe-event-service' import { renderSubscriptionsPage } from '../components/subscriptions-page' +import { renderEventsPage } from '../components/events-page' +import { renderStripeTabBar } from '../components/tab-bar' import type { Bindings, Variables } from '../../../../app' import type { StripePluginSettings, SubscriptionStatus } from '../types' import { DEFAULT_SETTINGS } from '../types' @@ -35,6 +38,7 @@ async function getSettings(db: any): Promise { // Subscriptions dashboard adminRoutes.get('/', async (c) => { const db = c.env.DB + const user = c.get('user') const subscriptionService = new SubscriptionService(db) await subscriptionService.ensureTable() @@ -49,15 +53,166 @@ adminRoutes.get('/', async (c) => { const totalPages = Math.ceil(total / limit) - const html = renderSubscriptionsPage(subscriptions as any, stats, { - status: statusFilter, - page, - totalPages + const html = renderSubscriptionsPage({ + subscriptions: subscriptions as any, + stats, + filters: { status: statusFilter, page, totalPages }, + user: user ? { name: user.email, email: user.email, role: user.role } : undefined, + version: c.get('appVersion'), + dynamicMenuItems: c.get('pluginMenuItems') }) return c.html(html) }) +// Events log page +adminRoutes.get('/events', async (c) => { + const db = c.env.DB + const user = c.get('user') + const eventService = new StripeEventService(db) + await eventService.ensureTable() + + const page = parseInt(c.req.query('page') || '1') + const limit = 50 + const typeFilter = c.req.query('type') || undefined + const statusFilter = c.req.query('status') as 'processed' | 'failed' | 'ignored' | undefined + + const [{ events, total }, stats, types] = await Promise.all([ + eventService.list({ type: typeFilter, status: statusFilter, page, limit }), + eventService.getStats(), + eventService.getDistinctTypes() + ]) + + const totalPages = Math.ceil(total / limit) + + const html = renderEventsPage({ + events, + stats, + types, + filters: { type: typeFilter, status: statusFilter, page, totalPages }, + user: user ? { name: user.email, email: user.email, role: user.role } : undefined, + version: c.get('appVersion'), + dynamicMenuItems: c.get('pluginMenuItems') + }) + + return c.html(html) +}) + +// Settings page +adminRoutes.get('/settings', async (c) => { + const db = c.env.DB + const user = c.get('user') + const settings = await getSettings(db) + + const { renderAdminLayoutCatalyst } = await import('../../../../templates/layouts/admin-layout-catalyst.template') + + const content = ` +
+
+

Stripe

+

+ Configure your Stripe API keys and checkout options. +

+
+ + ${renderStripeTabBar('/admin/plugins/stripe/settings')} + + + + +
+
+ + +

Your Stripe publishable key (starts with pk_)

+
+ +
+ + +

Your Stripe secret API key (starts with sk_)

+
+ +
+ + +

Stripe webhook endpoint signing secret (starts with whsec_)

+
+ +
+ + +

Default Stripe Price ID for checkout sessions (optional)

+
+ +
+ + +

Redirect URL after successful checkout

+
+ +
+ + +

Redirect URL if checkout is cancelled

+
+
+ +
+ +
+ +
+ + + ` + + return c.html(renderAdminLayoutCatalyst({ + title: 'Stripe Settings', + pageTitle: 'Stripe Settings', + currentPath: '/admin/plugins/stripe', + user: user ? { name: user.email, email: user.email, role: user.role } : undefined, + content, + version: c.get('appVersion'), + dynamicMenuItems: c.get('pluginMenuItems') + })) +}) + // Save settings adminRoutes.post('/settings', async (c) => { try { diff --git a/packages/core/src/plugins/core-plugins/stripe-plugin/routes/api.ts b/packages/core/src/plugins/core-plugins/stripe-plugin/routes/api.ts index 16e91e508..86e1c6f75 100644 --- a/packages/core/src/plugins/core-plugins/stripe-plugin/routes/api.ts +++ b/packages/core/src/plugins/core-plugins/stripe-plugin/routes/api.ts @@ -2,6 +2,7 @@ import { Hono } from 'hono' import { requireAuth } from '../../../../middleware' import { PluginService } from '../../../../services' import { SubscriptionService } from '../services/subscription-service' +import { StripeEventService } from '../services/stripe-event-service' import { StripeAPI } from '../services/stripe-api' import type { Bindings, Variables } from '../../../../app' import type { @@ -72,7 +73,13 @@ apiRoutes.post('/webhook', async (c) => { const event: StripeEvent = JSON.parse(rawBody) const subscriptionService = new SubscriptionService(db) - await subscriptionService.ensureTable() + const eventService = new StripeEventService(db) + await Promise.all([subscriptionService.ensureTable(), eventService.ensureTable()]) + + // Determine object ID and type for the event log + const obj = event.data.object as any + const objectId = obj?.id || '' + const objectType = obj?.object || event.type.split('.')[0] || '' try { switch (event.type) { @@ -123,7 +130,6 @@ apiRoutes.post('/webhook', async (c) => { const session = event.data.object as unknown as StripeCheckoutSession const userId = session.metadata?.sonicjs_user_id - // Link the Stripe customer to the user if we have a userId and subscription if (userId && session.subscription) { const existing = await subscriptionService.getByStripeSubscriptionId(session.subscription) if (existing && !existing.userId) { @@ -161,8 +167,38 @@ apiRoutes.post('/webhook', async (c) => { default: console.log(`[Stripe] Unhandled event type: ${event.type}`) + await eventService.log({ + stripeEventId: event.id, + type: event.type, + objectId, + objectType, + data: event.data.object as any, + status: 'ignored' + }) + return c.json({ received: true }) } + + // Log successfully processed event + await eventService.log({ + stripeEventId: event.id, + type: event.type, + objectId, + objectType, + data: event.data.object as any, + status: 'processed' + }) } catch (error) { + // Log failed event + await eventService.log({ + stripeEventId: event.id, + type: event.type, + objectId, + objectType, + data: event.data.object as any, + status: 'failed', + error: error instanceof Error ? error.message : String(error) + }).catch(() => {}) // Don't let logging failure mask the real error + console.error(`[Stripe] Error processing webhook event ${event.type}:`, error) return c.json({ error: 'Webhook processing failed' }, 500) } @@ -280,4 +316,92 @@ apiRoutes.get('/stats', requireAuth(), async (c) => { return c.json(stats) }) +// ============================================================================ +// Sync Subscriptions from Stripe API +// ============================================================================ + +apiRoutes.post('/sync-subscriptions', requireAuth(), async (c) => { + const user = c.get('user') + if (user?.role !== 'admin') return c.json({ error: 'Access denied' }, 403) + + const db = c.env.DB + const settings = await getSettings(db) + + if (!settings.stripeSecretKey) { + return c.json({ error: 'Stripe secret key not configured' }, 400) + } + + const stripeApi = new StripeAPI(settings.stripeSecretKey) + const subscriptionService = new SubscriptionService(db) + await subscriptionService.ensureTable() + + try { + const allSubs = await stripeApi.listAllSubscriptions() + let synced = 0 + let errors = 0 + + for (const sub of allSubs) { + try { + const userId = sub.metadata?.sonicjs_user_id || await subscriptionService.getUserIdByStripeCustomer(sub.customer) || '' + await subscriptionService.upsert({ + userId, + stripeCustomerId: typeof sub.customer === 'string' ? sub.customer : sub.customer.id, + stripeSubscriptionId: sub.id, + stripePriceId: sub.items?.data?.[0]?.price?.id || '', + status: mapStripeStatus(sub.status), + currentPeriodStart: sub.current_period_start, + currentPeriodEnd: sub.current_period_end, + cancelAtPeriodEnd: sub.cancel_at_period_end + }) + synced++ + } catch (err) { + console.error(`[Stripe Sync] Failed to upsert subscription ${sub.id}:`, err) + errors++ + } + } + + return c.json({ + success: true, + total: allSubs.length, + synced, + errors + }) + } catch (error) { + console.error('[Stripe Sync] Error:', error) + return c.json({ + success: false, + error: error instanceof Error ? error.message : 'Sync failed' + }, 500) + } +}) + +// ============================================================================ +// Stripe Events Log +// ============================================================================ + +apiRoutes.get('/events', requireAuth(), async (c) => { + const user = c.get('user') + if (user?.role !== 'admin') return c.json({ error: 'Access denied' }, 403) + + const db = c.env.DB + const eventService = new StripeEventService(db) + await eventService.ensureTable() + + const filters = { + type: c.req.query('type') || undefined, + status: c.req.query('status') as any || undefined, + objectId: c.req.query('objectId') || undefined, + page: c.req.query('page') ? parseInt(c.req.query('page')!) : 1, + limit: c.req.query('limit') ? parseInt(c.req.query('limit')!) : 50 + } + + const [result, stats, types] = await Promise.all([ + eventService.list(filters), + eventService.getStats(), + eventService.getDistinctTypes() + ]) + + return c.json({ ...result, stats, types }) +}) + export { apiRoutes as stripeApiRoutes } diff --git a/packages/core/src/plugins/core-plugins/stripe-plugin/services/stripe-api.ts b/packages/core/src/plugins/core-plugins/stripe-plugin/services/stripe-api.ts index d62bbed62..b3c49af57 100644 --- a/packages/core/src/plugins/core-plugins/stripe-plugin/services/stripe-api.ts +++ b/packages/core/src/plugins/core-plugins/stripe-plugin/services/stripe-api.ts @@ -98,6 +98,38 @@ export class StripeAPI { return this.request('POST', '/customers', body) } + /** + * List subscriptions with pagination (auto-expands across pages) + */ + async listSubscriptions(params?: { + status?: string + limit?: number + startingAfter?: string + }): Promise<{ data: any[]; has_more: boolean }> { + const qs = new URLSearchParams() + qs.append('limit', String(params?.limit || 100)) + if (params?.status) qs.append('status', params.status) + if (params?.startingAfter) qs.append('starting_after', params.startingAfter) + return this.request('GET', `/subscriptions?${qs.toString()}`) + } + + /** + * Fetch ALL subscriptions from Stripe (handles pagination automatically) + */ + async listAllSubscriptions(): Promise { + const all: any[] = [] + let startingAfter: string | undefined + + while (true) { + const result = await this.listSubscriptions({ limit: 100, startingAfter }) + all.push(...result.data) + if (!result.has_more || result.data.length === 0) break + startingAfter = result.data[result.data.length - 1].id + } + + return all + } + /** * Search for a customer by email */ diff --git a/packages/core/src/plugins/core-plugins/stripe-plugin/services/stripe-event-service.ts b/packages/core/src/plugins/core-plugins/stripe-plugin/services/stripe-event-service.ts new file mode 100644 index 000000000..b86e0a8d9 --- /dev/null +++ b/packages/core/src/plugins/core-plugins/stripe-plugin/services/stripe-event-service.ts @@ -0,0 +1,137 @@ +import type { D1Database } from '@cloudflare/workers-types' +import type { StripeEventRecord, StripeEventFilters, StripeEventStats } from '../types' + +/** + * Manages Stripe event log records in D1 + */ +export class StripeEventService { + constructor(private db: D1Database) {} + + async ensureTable(): Promise { + await this.db.prepare(` + CREATE TABLE IF NOT EXISTS stripe_events ( + id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))), + stripe_event_id TEXT NOT NULL UNIQUE, + type TEXT NOT NULL, + object_id TEXT NOT NULL DEFAULT '', + object_type TEXT NOT NULL DEFAULT '', + data TEXT NOT NULL DEFAULT '{}', + processed_at INTEGER NOT NULL DEFAULT (unixepoch()), + status TEXT NOT NULL DEFAULT 'processed', + error TEXT + ) + `).run() + + await this.db.prepare(` + CREATE INDEX IF NOT EXISTS idx_stripe_events_type ON stripe_events(type) + `).run() + await this.db.prepare(` + CREATE INDEX IF NOT EXISTS idx_stripe_events_status ON stripe_events(status) + `).run() + await this.db.prepare(` + CREATE INDEX IF NOT EXISTS idx_stripe_events_processed_at ON stripe_events(processed_at DESC) + `).run() + } + + async log(event: { + stripeEventId: string + type: string + objectId: string + objectType: string + data: Record + status: 'processed' | 'failed' | 'ignored' + error?: string + }): Promise { + await this.db.prepare(` + INSERT INTO stripe_events (stripe_event_id, type, object_id, object_type, data, status, error) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(stripe_event_id) DO UPDATE SET + status = excluded.status, + error = excluded.error, + processed_at = unixepoch() + `).bind( + event.stripeEventId, + event.type, + event.objectId, + event.objectType, + JSON.stringify(event.data), + event.status, + event.error || null + ).run() + } + + async list(filters: StripeEventFilters = {}): Promise<{ events: StripeEventRecord[]; total: number }> { + const where: string[] = [] + const values: any[] = [] + + if (filters.type) { + where.push('type = ?') + values.push(filters.type) + } + if (filters.status) { + where.push('status = ?') + values.push(filters.status) + } + if (filters.objectId) { + where.push('object_id = ?') + values.push(filters.objectId) + } + + const whereClause = where.length > 0 ? `WHERE ${where.join(' AND ')}` : '' + const limit = Math.min(filters.limit || 50, 100) + const page = filters.page || 1 + const offset = (page - 1) * limit + + const countResult = await this.db.prepare( + `SELECT COUNT(*) as count FROM stripe_events ${whereClause}` + ).bind(...values).first() as { count: number } + + const results = await this.db.prepare( + `SELECT * FROM stripe_events ${whereClause} ORDER BY processed_at DESC LIMIT ? OFFSET ?` + ).bind(...values, limit, offset).all() + + return { + events: (results.results || []).map((r: any) => this.mapRow(r)), + total: countResult?.count || 0 + } + } + + async getStats(): Promise { + const result = await this.db.prepare(` + SELECT + COUNT(*) as total, + SUM(CASE WHEN status = 'processed' THEN 1 ELSE 0 END) as processed, + SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) as failed, + SUM(CASE WHEN status = 'ignored' THEN 1 ELSE 0 END) as ignored + FROM stripe_events + `).first() as any + + return { + total: result?.total || 0, + processed: result?.processed || 0, + failed: result?.failed || 0, + ignored: result?.ignored || 0 + } + } + + async getDistinctTypes(): Promise { + const results = await this.db.prepare( + 'SELECT DISTINCT type FROM stripe_events ORDER BY type' + ).all() + return (results.results || []).map((r: any) => r.type) + } + + private mapRow(row: Record): StripeEventRecord { + return { + id: row.id, + stripeEventId: row.stripe_event_id, + type: row.type, + objectId: row.object_id, + objectType: row.object_type, + data: row.data, + processedAt: row.processed_at, + status: row.status, + error: row.error || undefined + } + } +} diff --git a/packages/core/src/plugins/core-plugins/stripe-plugin/services/subscription-service.ts b/packages/core/src/plugins/core-plugins/stripe-plugin/services/subscription-service.ts index 2738ce958..911a64c9a 100644 --- a/packages/core/src/plugins/core-plugins/stripe-plugin/services/subscription-service.ts +++ b/packages/core/src/plugins/core-plugins/stripe-plugin/services/subscription-service.ts @@ -70,6 +70,35 @@ export class SubscriptionService { return this.mapRow(result as any) } + /** + * Upsert a subscription by stripe_subscription_id (INSERT or UPDATE on conflict) + */ + async upsert(data: SubscriptionInsert): Promise { + const result = await this.db.prepare(` + INSERT INTO subscriptions (user_id, stripe_customer_id, stripe_subscription_id, stripe_price_id, status, current_period_start, current_period_end, cancel_at_period_end) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(stripe_subscription_id) DO UPDATE SET + status = excluded.status, + stripe_price_id = excluded.stripe_price_id, + current_period_start = excluded.current_period_start, + current_period_end = excluded.current_period_end, + cancel_at_period_end = excluded.cancel_at_period_end, + updated_at = unixepoch() + RETURNING * + `).bind( + data.userId, + data.stripeCustomerId, + data.stripeSubscriptionId, + data.stripePriceId, + data.status, + data.currentPeriodStart, + data.currentPeriodEnd, + data.cancelAtPeriodEnd ? 1 : 0 + ).first() + + return this.mapRow(result as any) + } + /** * Update a subscription by its Stripe subscription ID */ diff --git a/packages/core/src/plugins/core-plugins/stripe-plugin/types.ts b/packages/core/src/plugins/core-plugins/stripe-plugin/types.ts index eb3d88fc1..42f528247 100644 --- a/packages/core/src/plugins/core-plugins/stripe-plugin/types.ts +++ b/packages/core/src/plugins/core-plugins/stripe-plugin/types.ts @@ -56,6 +56,7 @@ export interface SubscriptionStats { } export interface StripePluginSettings { + stripePublishableKey: string stripeSecretKey: string stripeWebhookSecret: string stripePriceId?: string @@ -64,6 +65,7 @@ export interface StripePluginSettings { } export const DEFAULT_SETTINGS: StripePluginSettings = { + stripePublishableKey: '', stripeSecretKey: '', stripeWebhookSecret: '', stripePriceId: '', @@ -121,3 +123,34 @@ export interface StripeInvoice { amount_paid: number currency: string } + +// ============================================================================ +// Stripe Events Log +// ============================================================================ + +export interface StripeEventRecord { + id: string + stripeEventId: string + type: string + objectId: string + objectType: string + data: string // JSON string + processedAt: number + status: 'processed' | 'failed' | 'ignored' + error?: string +} + +export interface StripeEventFilters { + type?: string + status?: 'processed' | 'failed' | 'ignored' + objectId?: string + page?: number + limit?: number +} + +export interface StripeEventStats { + total: number + processed: number + failed: number + ignored: number +} diff --git a/packages/core/src/plugins/core-plugins/testimonials/manifest.json b/packages/core/src/plugins/core-plugins/testimonials/manifest.json index 8d0422ebf..e295fc635 100644 --- a/packages/core/src/plugins/core-plugins/testimonials/manifest.json +++ b/packages/core/src/plugins/core-plugins/testimonials/manifest.json @@ -8,7 +8,12 @@ "repository": "https://github.com/lane711/sonicjs-ai/tree/main/src/plugins/core-plugins", "license": "MIT", "category": "content", - "tags": ["testimonials", "reviews", "feedback", "ratings"], + "tags": [ + "testimonials", + "reviews", + "feedback", + "ratings" + ], "dependencies": [], "settings": {}, "hooks": { @@ -18,5 +23,8 @@ "routes": [], "permissions": { "testimonials:manage": "Manage testimonials" - } + }, + "iconEmoji": "πŸ’¬", + "is_core": false, + "defaultSettings": {} } diff --git a/packages/core/src/plugins/core-plugins/turnstile-plugin/manifest.json b/packages/core/src/plugins/core-plugins/turnstile-plugin/manifest.json index 5a928a7d8..4b5704451 100644 --- a/packages/core/src/plugins/core-plugins/turnstile-plugin/manifest.json +++ b/packages/core/src/plugins/core-plugins/turnstile-plugin/manifest.json @@ -9,7 +9,10 @@ "homepage": "https://developers.cloudflare.com/turnstile/", "repository": "https://github.com/sonicjs/sonicjs", "license": "MIT", - "permissions": ["settings:write", "admin:access"], + "permissions": [ + "settings:write", + "admin:access" + ], "dependencies": [], "configSchema": { "siteKey": { @@ -31,9 +34,18 @@ "description": "Visual theme for the Turnstile widget", "default": "auto", "options": [ - { "value": "light", "label": "Light" }, - { "value": "dark", "label": "Dark" }, - { "value": "auto", "label": "Auto" } + { + "value": "light", + "label": "Light" + }, + { + "value": "dark", + "label": "Dark" + }, + { + "value": "auto", + "label": "Auto" + } ] }, "size": { @@ -42,8 +54,14 @@ "description": "Size of the Turnstile widget", "default": "normal", "options": [ - { "value": "normal", "label": "Normal" }, - { "value": "compact", "label": "Compact" } + { + "value": "normal", + "label": "Normal" + }, + { + "value": "compact", + "label": "Compact" + } ] }, "mode": { @@ -52,9 +70,18 @@ "description": "Managed: Adaptive challenge. Non-Interactive: Always visible, minimal friction. Invisible: No visible widget", "default": "managed", "options": [ - { "value": "managed", "label": "Managed (Recommended)" }, - { "value": "non-interactive", "label": "Non-Interactive" }, - { "value": "invisible", "label": "Invisible" } + { + "value": "managed", + "label": "Managed (Recommended)" + }, + { + "value": "non-interactive", + "label": "Non-Interactive" + }, + { + "value": "invisible", + "label": "Invisible" + } ] }, "appearance": { @@ -63,9 +90,18 @@ "description": "When the Turnstile challenge is executed. Always: Verifies immediately. Execute: Challenge on form submit. Interaction Only: Only after user interaction", "default": "always", "options": [ - { "value": "always", "label": "Always" }, - { "value": "execute", "label": "Execute" }, - { "value": "interaction-only", "label": "Interaction Only" } + { + "value": "always", + "label": "Always" + }, + { + "value": "execute", + "label": "Execute" + }, + { + "value": "interaction-only", + "label": "Interaction Only" + } ] }, "preClearance": { @@ -80,9 +116,18 @@ "description": "Controls which Cloudflare Firewall Rules the clearance cookie bypasses. Only applies if Pre-clearance is enabled", "default": "managed", "options": [ - { "value": "interactive", "label": "Interactive - Bypasses Interactive, Managed & JS Challenge Rules" }, - { "value": "managed", "label": "Managed - Bypasses Managed & JS Challenge Rules" }, - { "value": "non-interactive", "label": "Non-interactive - Bypasses JS Challenge Rules only" } + { + "value": "interactive", + "label": "Interactive - Bypasses Interactive, Managed & JS Challenge Rules" + }, + { + "value": "managed", + "label": "Managed - Bypasses Managed & JS Challenge Rules" + }, + { + "value": "non-interactive", + "label": "Non-interactive - Bypasses JS Challenge Rules only" + } ] }, "enabled": { @@ -98,5 +143,19 @@ "href": "/admin/plugins/turnstile/settings", "parentId": "plugins", "order": 100 + }, + "codeName": "turnstile-plugin", + "iconEmoji": "πŸ›‘οΈ", + "is_core": true, + "defaultSettings": { + "siteKey": "", + "secretKey": "", + "theme": "auto", + "size": "normal", + "mode": "managed", + "appearance": "always", + "preClearanceEnabled": false, + "preClearanceLevel": "managed", + "enabled": false } } diff --git a/packages/core/src/plugins/core-plugins/user-profiles/manifest.json b/packages/core/src/plugins/core-plugins/user-profiles/manifest.json index 69e47b3a7..5a83f7a7d 100644 --- a/packages/core/src/plugins/core-plugins/user-profiles/manifest.json +++ b/packages/core/src/plugins/core-plugins/user-profiles/manifest.json @@ -9,7 +9,10 @@ }, "category": "users", "icon": "user", - "isCore": true, "dependencies": [], - "settings": {} + "settings": {}, + "id": "user-profiles", + "iconEmoji": "πŸ‘€", + "is_core": true, + "defaultSettings": {} } diff --git a/packages/core/src/plugins/core-plugins/workflow-plugin/manifest.json b/packages/core/src/plugins/core-plugins/workflow-plugin/manifest.json index 5c5d0a6e6..2b21d39eb 100644 --- a/packages/core/src/plugins/core-plugins/workflow-plugin/manifest.json +++ b/packages/core/src/plugins/core-plugins/workflow-plugin/manifest.json @@ -8,7 +8,13 @@ "repository": "https://github.com/lane711/sonicjs-ai/tree/main/src/plugins/core-plugins/workflow-plugin", "license": "MIT", "category": "content", - "tags": ["workflow", "approval", "review", "publishing", "collaboration"], + "tags": [ + "workflow", + "approval", + "review", + "publishing", + "collaboration" + ], "dependencies": [], "settings": { "requireApproval": { @@ -32,5 +38,13 @@ "permissions": { "workflow:manage": "Manage workflow settings", "workflow:approve": "Approve content" + }, + "iconEmoji": "πŸ”„", + "is_core": false, + "defaultSettings": { + "enableApprovalChains": true, + "enableScheduling": true, + "enableAutomation": true, + "enableNotifications": true } } diff --git a/packages/core/src/plugins/design/manifest.json b/packages/core/src/plugins/design/manifest.json index be04bc61f..9e59d2088 100644 --- a/packages/core/src/plugins/design/manifest.json +++ b/packages/core/src/plugins/design/manifest.json @@ -8,7 +8,13 @@ "repository": "https://github.com/lane711/sonicjs-ai/tree/main/src/plugins/design", "license": "MIT", "category": "utilities", - "tags": ["design", "ui", "themes", "components", "customization"], + "tags": [ + "design", + "ui", + "themes", + "components", + "customization" + ], "dependencies": [], "settings": { "defaultTheme": { @@ -16,7 +22,11 @@ "label": "Default Theme", "description": "Default theme to use for the admin interface", "default": "dark", - "options": ["light", "dark", "auto"] + "options": [ + "light", + "dark", + "auto" + ] }, "customCSS": { "type": "boolean", @@ -46,5 +56,11 @@ "icon": "palette", "path": "/admin/design", "order": 80 + }, + "iconEmoji": "🎨", + "is_core": false, + "defaultSettings": { + "defaultTheme": "light", + "customCSS": "" } } diff --git a/packages/core/src/plugins/manifest-registry.ts b/packages/core/src/plugins/manifest-registry.ts new file mode 100644 index 000000000..765379297 --- /dev/null +++ b/packages/core/src/plugins/manifest-registry.ts @@ -0,0 +1,769 @@ +/** + * Plugin Registry - AUTO-GENERATED + * + * Generated by: packages/scripts/generate-plugin-registry.mjs + * Generated at: 2026-04-10T00:01:03.856Z + * Source: All manifest.json files in src/plugins/ + * + * DO NOT EDIT MANUALLY - run the generator script instead. + * To add a new plugin, create a manifest.json in the plugin directory. + */ + +export interface PluginRegistryEntry { + id: string + codeName: string + displayName: string + description: string + version: string + author: string + category: string + iconEmoji: string + is_core: boolean + permissions: string[] + dependencies: string[] + defaultSettings: Record + adminMenu: { + label: string + icon: string + path: string + order: number + } | null +} + +/** + * All discovered plugins, keyed by plugin ID. + */ +export const PLUGIN_REGISTRY: Record = { + 'ai-search': { + "id": "ai-search", + "codeName": "ai-search-plugin", + "displayName": "AI Search", + "description": "Advanced search with Cloudflare AI Search. Full-text search, semantic search, and advanced filtering across all content collections.", + "version": "1.0.0", + "author": "SonicJS", + "category": "content", + "iconEmoji": "πŸ”", + "is_core": true, + "permissions": [ + "settings:write", + "admin:access", + "content:read" + ], + "dependencies": [], + "defaultSettings": { + "enabled": true, + "ai_mode_enabled": true, + "selected_collections": [], + "dismissed_collections": [], + "autocomplete_enabled": true, + "cache_duration": 1, + "results_limit": 20, + "index_media": false + }, + "adminMenu": { + "label": "AI Search", + "icon": "magnifying-glass", + "path": "/admin/plugins/ai-search", + "order": 50 + } + }, + + 'code-examples-plugin': { + "id": "code-examples-plugin", + "codeName": "code-examples-plugin", + "displayName": "Code Examples", + "description": "Code snippets and examples library with syntax highlighting and categorization", + "version": "1.0.0-beta.1", + "author": "SonicJS Team", + "category": "content", + "iconEmoji": "πŸ’»", + "is_core": false, + "permissions": [ + "code-examples:manage" + ], + "dependencies": [], + "defaultSettings": {}, + "adminMenu": null + }, + + 'core-analytics': { + "id": "core-analytics", + "codeName": "core-analytics", + "displayName": "Analytics & Insights", + "description": "Core analytics system for tracking page views, user behavior, and content performance. Provides dashboards and reports with real-time metrics", + "version": "1.0.0-beta.1", + "author": "SonicJS Team", + "category": "seo", + "iconEmoji": "πŸ“Š", + "is_core": true, + "permissions": [ + "analytics:view", + "analytics:export" + ], + "dependencies": [], + "defaultSettings": {}, + "adminMenu": { + "label": "Analytics", + "icon": "chart-bar", + "path": "/admin/analytics", + "order": 50 + } + }, + + 'core-auth': { + "id": "core-auth", + "codeName": "core-auth", + "displayName": "Authentication System", + "description": "Core authentication and user management system with role-based access control, session management, and security features", + "version": "1.0.0-beta.1", + "author": "SonicJS Team", + "category": "security", + "iconEmoji": "πŸ”", + "is_core": true, + "permissions": [ + "manage:users", + "manage:roles", + "manage:permissions" + ], + "dependencies": [], + "defaultSettings": { + "requiredFields": { + "email": { + "required": true, + "minLength": 5, + "label": "Email", + "type": "email" + }, + "password": { + "required": true, + "minLength": 8, + "label": "Password", + "type": "password" + }, + "username": { + "required": true, + "minLength": 3, + "label": "Username", + "type": "text" + }, + "firstName": { + "required": true, + "minLength": 1, + "label": "First Name", + "type": "text" + }, + "lastName": { + "required": true, + "minLength": 1, + "label": "Last Name", + "type": "text" + } + }, + "validation": { + "emailFormat": true, + "allowDuplicateUsernames": false, + "passwordRequirements": { + "requireUppercase": false, + "requireLowercase": false, + "requireNumbers": false, + "requireSpecialChars": false + } + }, + "registration": { + "enabled": true, + "requireEmailVerification": false, + "defaultRole": "viewer" + } + }, + "adminMenu": null + }, + + 'core-cache': { + "id": "core-cache", + "codeName": "core-cache", + "displayName": "Cache System", + "description": "Three-tiered caching system with in-memory and KV storage. Provides automatic caching for content, users, media, and API responses with configurable TTL and invalidation patterns.", + "version": "1.0.0-beta.1", + "author": "SonicJS", + "category": "system", + "iconEmoji": "⚑", + "is_core": true, + "permissions": [ + "cache.view", + "cache.clear", + "cache.invalidate" + ], + "dependencies": [], + "defaultSettings": { + "enableMemoryCache": true, + "enableKVCache": true, + "enableDatabaseCache": true, + "defaultTTL": 3600 + }, + "adminMenu": null + }, + + 'core-media': { + "id": "core-media", + "codeName": "core-media", + "displayName": "Media Manager", + "description": "Core media upload and management system with support for images, videos, and documents. Includes automatic optimization, thumbnail generation, and cloud storage integration", + "version": "1.0.0-beta.1", + "author": "SonicJS Team", + "category": "media", + "iconEmoji": "πŸ“Έ", + "is_core": true, + "permissions": [ + "manage:media", + "upload:files" + ], + "dependencies": [], + "defaultSettings": {}, + "adminMenu": { + "label": "Media", + "icon": "image", + "path": "/admin/media", + "order": 30 + } + }, + + 'database-tools': { + "id": "database-tools", + "codeName": "database-tools", + "displayName": "Database Tools", + "description": "Database management and administration tools including migrations, backups, and query execution", + "version": "1.0.0-beta.1", + "author": "SonicJS Team", + "category": "development", + "iconEmoji": "πŸ—„οΈ", + "is_core": false, + "permissions": [ + "database:admin" + ], + "dependencies": [], + "defaultSettings": { + "enableTruncate": true, + "enableBackup": true, + "enableValidation": true, + "requireConfirmation": true + }, + "adminMenu": null + }, + + 'demo-login-plugin': { + "id": "demo-login-plugin", + "codeName": "demo-login-plugin", + "displayName": "Demo Login", + "description": "Quick demo login functionality for testing and demonstrations", + "version": "1.0.0-beta.1", + "author": "SonicJS Team", + "category": "utilities", + "iconEmoji": "🎯", + "is_core": false, + "permissions": [], + "dependencies": [ + "core-auth" + ], + "defaultSettings": { + "enableNotice": true, + "demoEmail": "admin@sonicjs.com", + "demoPassword": "sonicjs!" + }, + "adminMenu": null + }, + + 'design': { + "id": "design", + "codeName": "design", + "displayName": "Design System", + "description": "Design system management including themes, components, and UI customization. Provides a visual interface for managing design tokens, typography, colors, and component library.", + "version": "1.0.0-beta.1", + "author": "SonicJS", + "category": "utilities", + "iconEmoji": "🎨", + "is_core": false, + "permissions": [ + "design.view", + "design.edit" + ], + "dependencies": [], + "defaultSettings": { + "defaultTheme": "light", + "customCSS": "" + }, + "adminMenu": { + "label": "Design", + "icon": "palette", + "path": "/admin/design", + "order": 80 + } + }, + + 'easy-mdx': { + "id": "easy-mdx", + "codeName": "easy-mdx", + "displayName": "EasyMDE Markdown Editor", + "description": "Lightweight markdown editor with live preview. Provides a simple and efficient editor with markdown support for richtext fields.", + "version": "1.0.0", + "author": "SonicJS Team", + "category": "editor", + "iconEmoji": "πŸ“", + "is_core": false, + "permissions": [], + "dependencies": [], + "defaultSettings": { + "defaultHeight": 400, + "theme": "dark", + "toolbar": "full", + "placeholder": "Start writing your content..." + }, + "adminMenu": null + }, + + 'email': { + "id": "email", + "codeName": "email", + "displayName": "Email", + "description": "Send transactional emails using Resend", + "version": "1.0.0-beta.1", + "author": "SonicJS Team", + "category": "utilities", + "iconEmoji": "πŸ“§", + "is_core": false, + "permissions": [ + "email:manage", + "email:send", + "email:view-logs" + ], + "dependencies": [], + "defaultSettings": { + "apiKey": "", + "fromEmail": "", + "fromName": "", + "replyTo": "", + "logoUrl": "" + }, + "adminMenu": { + "label": "Email", + "icon": "envelope", + "path": "/admin/plugins/email/settings", + "order": 80 + } + }, + + 'global-variables': { + "id": "global-variables", + "codeName": "global-variables", + "displayName": "Global Variables", + "description": "Dynamic content variables that can be referenced as inline tokens in rich text fields. Supports {variable_key} syntax with server-side resolution.", + "version": "1.0.0-beta.1", + "author": "SonicJS Team", + "category": "content", + "iconEmoji": "πŸ”€", + "is_core": false, + "permissions": [ + "global-variables:manage", + "global-variables:view" + ], + "dependencies": [], + "defaultSettings": { + "enableResolution": true, + "cacheEnabled": true, + "cacheTTL": 300 + }, + "adminMenu": null + }, + + 'hello-world': { + "id": "hello-world", + "codeName": "hello-world", + "displayName": "Hello World", + "description": "A simple Hello World plugin demonstration", + "version": "1.0.0-beta.1", + "author": "SonicJS Team", + "category": "utilities", + "iconEmoji": "πŸ‘‹", + "is_core": false, + "permissions": [ + "hello-world:view" + ], + "dependencies": [], + "defaultSettings": {}, + "adminMenu": { + "label": "Hello World", + "icon": "hand-raised", + "path": "/admin/hello-world", + "order": 90 + } + }, + + 'magic-link-auth': { + "id": "magic-link-auth", + "codeName": "magic-link-auth", + "displayName": "Magic Link Authentication", + "description": "Passwordless authentication via email magic links. Users receive a secure one-time link to sign in without entering a password.", + "version": "1.0.0", + "author": "SonicJS Team", + "category": "security", + "iconEmoji": "πŸ”—", + "is_core": false, + "permissions": [], + "dependencies": [ + "email" + ], + "defaultSettings": { + "linkExpiryMinutes": 15, + "rateLimitPerHour": 5, + "allowNewUsers": true + }, + "adminMenu": null + }, + + 'oauth-providers': { + "id": "oauth-providers", + "codeName": "oauth-providers", + "displayName": "OAuth Providers", + "description": "OAuth2/OIDC social login with GitHub, Google, and more", + "version": "1.0.0-beta.1", + "author": "SonicJS Team", + "category": "authentication", + "iconEmoji": "πŸ”‘", + "is_core": true, + "permissions": [], + "dependencies": [], + "defaultSettings": { + "providers": { + "github": { + "clientId": "", + "clientSecret": "", + "enabled": false + }, + "google": { + "clientId": "", + "clientSecret": "", + "enabled": false + } + } + }, + "adminMenu": null + }, + + 'otp-login': { + "id": "otp-login", + "codeName": "otp-login", + "displayName": "OTP Login", + "description": "Passwordless authentication via email one-time codes", + "version": "1.0.0-beta.1", + "author": "SonicJS Team", + "category": "security", + "iconEmoji": "πŸ”’", + "is_core": false, + "permissions": [ + "otp:manage", + "otp:request", + "otp:verify" + ], + "dependencies": [ + "email" + ], + "defaultSettings": { + "codeLength": 6, + "codeExpiryMinutes": 10, + "maxAttempts": 3, + "rateLimitPerHour": 5, + "allowNewUserRegistration": false + }, + "adminMenu": { + "label": "OTP Login", + "icon": "key", + "path": "/admin/plugins/otp-login/settings", + "order": 85 + } + }, + + 'quill-editor': { + "id": "quill-editor", + "codeName": "quill-editor", + "displayName": "Quill Rich Text Editor", + "description": "Quill WYSIWYG editor integration for rich text editing. Lightweight, modern editor with customizable toolbars and dark mode support.", + "version": "1.0.0", + "author": "SonicJS Team", + "category": "editor", + "iconEmoji": "✍️", + "is_core": true, + "permissions": [], + "dependencies": [], + "defaultSettings": { + "version": "2.0.2", + "defaultHeight": 300, + "defaultToolbar": "full", + "theme": "snow" + }, + "adminMenu": null + }, + + 'redirect-management': { + "id": "redirect-management", + "codeName": "redirect-management", + "displayName": "Redirect Management", + "description": "URL redirect management with exact, partial, and regex matching", + "version": "1.0.0", + "author": "ahaas", + "category": "utilities", + "iconEmoji": "β†ͺ️", + "is_core": false, + "permissions": [ + "redirect.manage", + "redirect.view" + ], + "dependencies": [], + "defaultSettings": { + "enabled": true, + "autoOffloadEnabled": false + }, + "adminMenu": { + "label": "Redirects", + "icon": "arrow-right", + "path": "/admin/redirects", + "order": 85 + } + }, + + 'security-audit': { + "id": "security-audit", + "codeName": "security-audit", + "displayName": "Security Audit", + "description": "Security event logging, brute-force detection, and analytics dashboard. Monitors login attempts, registrations, lockouts, and suspicious activity.", + "version": "1.0.0-beta.1", + "author": "SonicJS Team", + "category": "security", + "iconEmoji": "πŸ›‘οΈ", + "is_core": false, + "permissions": [ + "security-audit:view", + "security-audit:manage" + ], + "dependencies": [], + "defaultSettings": { + "retention": { + "daysToKeep": 90, + "maxEvents": 100000, + "autoPurge": true + }, + "bruteForce": { + "enabled": true, + "maxFailedAttemptsPerIP": 10, + "maxFailedAttemptsPerEmail": 5, + "windowMinutes": 15, + "lockoutDurationMinutes": 30, + "alertThreshold": 20 + }, + "logging": { + "logSuccessfulLogins": true, + "logLogouts": true, + "logRegistrations": true, + "logPasswordResets": true, + "logPermissionDenied": true + } + }, + "adminMenu": { + "label": "Security Audit", + "icon": "shield-check", + "path": "/admin/plugins/security-audit", + "order": 85 + } + }, + + 'seed-data': { + "id": "seed-data", + "codeName": "seed-data", + "displayName": "Seed Data Generator", + "description": "Development tool for generating sample data and testing content. Useful for demos and development environments", + "version": "1.0.0-beta.1", + "author": "SonicJS Team", + "category": "development", + "iconEmoji": "🌱", + "is_core": false, + "permissions": [ + "seed-data:generate" + ], + "dependencies": [], + "defaultSettings": { + "userCount": 20, + "contentCount": 200, + "defaultPassword": "password123" + }, + "adminMenu": null + }, + + 'stripe': { + "id": "stripe", + "codeName": "stripe", + "displayName": "Stripe Subscriptions", + "description": "Stripe subscription management with webhook handling, checkout sessions, and subscription gating", + "version": "1.0.0-beta.1", + "author": "SonicJS Team", + "category": "payments", + "iconEmoji": "πŸ’³", + "is_core": true, + "permissions": [ + "stripe:manage", + "stripe:view" + ], + "dependencies": [], + "defaultSettings": { + "stripeSecretKey": "", + "stripeWebhookSecret": "", + "stripePriceId": "", + "successUrl": "/admin/dashboard", + "cancelUrl": "/admin/dashboard" + }, + "adminMenu": { + "label": "Stripe", + "icon": "credit-card", + "path": "/admin/plugins/stripe", + "order": 90 + } + }, + + 'testimonials-plugin': { + "id": "testimonials-plugin", + "codeName": "testimonials-plugin", + "displayName": "Testimonials", + "description": "Customer testimonials and reviews management with display widgets and ratings", + "version": "1.0.0-beta.1", + "author": "SonicJS Team", + "category": "content", + "iconEmoji": "πŸ’¬", + "is_core": false, + "permissions": [ + "testimonials:manage" + ], + "dependencies": [], + "defaultSettings": {}, + "adminMenu": null + }, + + 'tinymce-plugin': { + "id": "tinymce-plugin", + "codeName": "tinymce-plugin", + "displayName": "TinyMCE Rich Text Editor", + "description": "Powerful WYSIWYG rich text editor for content creation. Provides a full-featured editor with formatting, media embedding, and customizable toolbars for richtext fields.", + "version": "1.0.0", + "author": "SonicJS Team", + "category": "editor", + "iconEmoji": "πŸ“", + "is_core": false, + "permissions": [], + "dependencies": [], + "defaultSettings": { + "apiKey": "no-api-key", + "defaultHeight": 300, + "defaultToolbar": "full", + "skin": "oxide-dark" + }, + "adminMenu": null + }, + + 'turnstile': { + "id": "turnstile", + "codeName": "turnstile-plugin", + "displayName": "Cloudflare Turnstile", + "description": "CAPTCHA-free bot protection using Cloudflare Turnstile. Provides reusable verification for any form.", + "version": "1.0.0", + "author": "SonicJS", + "category": "security", + "iconEmoji": "πŸ›‘οΈ", + "is_core": true, + "permissions": [ + "settings:write", + "admin:access" + ], + "dependencies": [], + "defaultSettings": { + "siteKey": "", + "secretKey": "", + "theme": "auto", + "size": "normal", + "mode": "managed", + "appearance": "always", + "preClearanceEnabled": false, + "preClearanceLevel": "managed", + "enabled": false + }, + "adminMenu": { + "label": "Turnstile", + "icon": "shield-check", + "path": "/admin/plugins/turnstile/settings", + "order": 100 + } + }, + + 'user-profiles': { + "id": "user-profiles", + "codeName": "user-profiles", + "displayName": "User Profiles", + "description": "Configurable custom profile fields for users", + "version": "1.0.0-beta.1", + "author": "SonicJS Team", + "category": "users", + "iconEmoji": "πŸ‘€", + "is_core": true, + "permissions": [], + "dependencies": [], + "defaultSettings": {}, + "adminMenu": null + }, + + 'workflow-plugin': { + "id": "workflow-plugin", + "codeName": "workflow-plugin", + "displayName": "Workflow Engine", + "description": "Content workflow and approval system with customizable states, transitions, and review processes", + "version": "1.0.0-beta.1", + "author": "SonicJS Team", + "category": "content", + "iconEmoji": "πŸ”„", + "is_core": false, + "permissions": [ + "workflow:manage", + "workflow:approve" + ], + "dependencies": [], + "defaultSettings": { + "enableApprovalChains": true, + "enableScheduling": true, + "enableAutomation": true, + "enableNotifications": true + }, + "adminMenu": null + } +} as const + +/** + * All plugin IDs. + */ +export const ALL_PLUGIN_IDS = Object.keys(PLUGIN_REGISTRY) + +/** + * Plugins that have their own admin page (have an adminMenu entry). + */ +export const PLUGINS_WITH_ADMIN_PAGES = ALL_PLUGIN_IDS.filter( + id => PLUGIN_REGISTRY[id]?.adminMenu !== null +) + +/** + * Look up a plugin by its codeName (the `name` field stored in the DB). + * Falls back to id lookup if no codeName match. + */ +export function findPluginByCodeName(codeName: string): PluginRegistryEntry | undefined { + return Object.values(PLUGIN_REGISTRY).find(p => p.codeName === codeName) + || PLUGIN_REGISTRY[codeName] +} + +/** + * Get a plugin by ID. + */ +export function getPlugin(id: string): PluginRegistryEntry | undefined { + return PLUGIN_REGISTRY[id] +} diff --git a/packages/core/src/plugins/redirect-management/manifest.json b/packages/core/src/plugins/redirect-management/manifest.json index 220a7f2bb..195815b29 100644 --- a/packages/core/src/plugins/redirect-management/manifest.json +++ b/packages/core/src/plugins/redirect-management/manifest.json @@ -7,7 +7,12 @@ "homepage": "https://sonicjs.com/plugins/redirect-management", "license": "MIT", "category": "utilities", - "tags": ["redirects", "seo", "urls", "utilities"], + "tags": [ + "redirects", + "seo", + "urls", + "utilities" + ], "dependencies": [], "settings": { "enabled": { @@ -33,5 +38,11 @@ "icon": "arrow-right", "path": "/admin/redirects", "order": 85 + }, + "iconEmoji": "β†ͺ️", + "is_core": false, + "defaultSettings": { + "enabled": true, + "autoOffloadEnabled": false } } diff --git a/packages/core/src/routes/admin-plugins.ts b/packages/core/src/routes/admin-plugins.ts index 483ef37e1..f0f71f3e5 100644 --- a/packages/core/src/routes/admin-plugins.ts +++ b/packages/core/src/routes/admin-plugins.ts @@ -4,8 +4,7 @@ import { renderPluginsListPage, PluginsListPageData, Plugin } from '../templates import { renderPluginSettingsPage, PluginSettingsPageData } from '../templates/pages/admin-plugin-settings.template' import { SettingsService } from '../services/settings' import { PluginService } from '../services' -// TODO: authValidationService not yet migrated - commented out temporarily -// import { authValidationService } from '../services/auth-validation' +import { PLUGIN_REGISTRY, PLUGINS_WITH_ADMIN_PAGES, findPluginByCodeName } from '../plugins/manifest-registry' import type { Bindings, Variables } from '../app' const adminPluginRoutes = new Hono<{ Bindings: Bindings; Variables: Variables }>() @@ -13,165 +12,35 @@ const adminPluginRoutes = new Hono<{ Bindings: Bindings; Variables: Variables }> // Apply authentication middleware adminPluginRoutes.use('*', requireAuth()) -// Available plugins registry - plugins that can be installed -const AVAILABLE_PLUGINS = [ - { - id: 'third-party-faq', - name: 'faq-plugin', - display_name: 'FAQ System', - description: 'Frequently Asked Questions management system with categories, search, and custom styling', - version: '2.0.0', - author: 'Community Developer', - category: 'content', - icon: 'ҝ“', - permissions: ['manage:faqs'], - dependencies: [], - is_core: false - }, - { - id: 'demo-login-prefill', - name: 'demo-login-plugin', - display_name: 'Demo Login Prefill', - description: 'Prefills login form with demo credentials (admin@sonicjs.com/sonicjs!) for easy site demonstration', - version: '1.0.0-beta.1', - author: 'SonicJS', - category: 'demo', - icon: '🎯', - permissions: [], - dependencies: [], - is_core: false - }, - { - id: 'database-tools', - name: 'database-tools', - display_name: 'Database Tools', - description: 'Database management tools including truncate, backup, and validation', - version: '1.0.0-beta.1', - author: 'SonicJS Team', - category: 'system', - icon: 'Γ°ΒŸΒ—Β„Γ―ΒΈΒ', - permissions: ['manage:database', 'admin'], - dependencies: [], - is_core: false - }, - { - id: 'seed-data', - name: 'seed-data', - display_name: 'Seed Data', - description: 'Generate realistic example users and content for testing and development', - version: '1.0.0-beta.1', - author: 'SonicJS Team', - category: 'development', - icon: '🌱', - permissions: ['admin'], - dependencies: [], - is_core: false - }, - { - id: 'quill-editor', - name: 'quill-editor', - display_name: 'Quill Rich Text Editor', - description: 'Quill WYSIWYG editor integration for rich text editing. Lightweight, modern editor with customizable toolbars and dark mode support.', - version: '1.0.0', - author: 'SonicJS Team', - category: 'editor', - icon: 'Ҝï¸', - permissions: [], - dependencies: [], - is_core: true - }, - { - id: 'tinymce-plugin', - name: 'tinymce-plugin', - display_name: 'TinyMCE Rich Text Editor', - description: 'Powerful WYSIWYG rich text editor for content creation. Provides a full-featured editor with formatting, media embedding, and customizable toolbars for richtext fields.', - version: '1.0.0', - author: 'SonicJS Team', - category: 'editor', - icon: 'Γ°ΒŸΒ“Β', - permissions: [], - dependencies: [], - is_core: false - }, - { - id: 'easy-mdx', - name: 'easy-mdx', - display_name: 'EasyMDE Markdown Editor', - description: 'Lightweight markdown editor with live preview. Provides a simple and efficient editor with markdown support for richtext fields.', - version: '1.0.0', - author: 'SonicJS Team', - category: 'editor', - icon: 'Γ°ΒŸΒ“Β', - permissions: [], - dependencies: [], - is_core: false - }, - { - id: 'turnstile', - name: 'turnstile-plugin', - display_name: 'Cloudflare Turnstile', - description: 'CAPTCHA-free bot protection for forms using Cloudflare Turnstile. Provides seamless spam prevention with configurable modes, themes, and pre-clearance options.', - version: '1.0.0', - author: 'SonicJS Team', - category: 'security', - icon: 'Γ°ΒŸΒ›Β‘Γ―ΒΈΒ', - permissions: [], - dependencies: [], - is_core: true - }, - { - id: 'security-audit', - name: 'security-audit', - display_name: 'Security Audit', - description: 'Security event logging, brute-force detection, and analytics dashboard. Monitors login attempts, registrations, lockouts, and suspicious activity.', - version: '1.0.0-beta.1', - author: 'SonicJS Team', - category: 'security', - icon: 'Γ°ΒŸΒ›Β‘Γ―ΒΈΒ', - permissions: ['security-audit:view', 'security-audit:manage'], - dependencies: [], - is_core: false - }, - { - id: 'ai-search', - name: 'ai-search-plugin', - display_name: 'AI Search', - description: 'Advanced search with Cloudflare AI Search. Full-text search, semantic search, and advanced filtering across all content collections.', - version: '1.0.0', - author: 'SonicJS Team', - category: 'search', - icon: 'Γ°ΒŸΒ”Β', - permissions: [], - dependencies: [], - is_core: true - }, - { - id: 'form-builder', - name: 'form-builder', - display_name: 'Form Builder', - description: 'Drag-and-drop form builder with conditional logic, file uploads, and email notifications. Create contact forms, surveys, and data collection forms.', - version: '1.0.0', - author: 'SonicJS Team', - category: 'content', - icon: '\u{1F4DD}', - permissions: ['forms:create', 'forms:manage', 'forms:submissions'], - dependencies: [], - is_core: false - } -] +// Build available plugins list from the auto-generated registry. +// To add a new plugin to this list, create a manifest.json in the plugin directory +// and run: node packages/scripts/generate-plugin-registry.mjs +const AVAILABLE_PLUGINS = Object.values(PLUGIN_REGISTRY).map(p => ({ + id: p.id, + name: p.codeName, + display_name: p.displayName, + description: p.description, + version: p.version, + author: p.author, + category: p.category, + icon: p.iconEmoji, + permissions: p.permissions, + dependencies: p.dependencies, + is_core: p.is_core +})) // Plugin list page adminPluginRoutes.get('/', async (c) => { try { const user = c.get('user') const db = c.env.DB - + // Temporarily skip permission check for admin users // TODO: Fix permission system if (user?.role !== 'admin') { return c.text('Access denied', 403) } - + const pluginService = new PluginService(db) // Get all installed plugins with error handling @@ -262,9 +131,8 @@ adminPluginRoutes.get('/:id', async (c) => { const db = c.env.DB const pluginId = c.req.param('id') - // Skip plugins that have their own custom settings pages (not using component system) - const pluginsWithCustomPages = ['ai-search', 'security-audit'] - if (pluginsWithCustomPages.includes(pluginId)) { + // Skip plugins that have their own custom admin pages (detected from registry adminMenu) + if (PLUGINS_WITH_ADMIN_PAGES.includes(pluginId)) { // Let the plugin's own route handle this return c.text('', 404) // Return 404 so Hono continues to next route } @@ -333,7 +201,7 @@ adminPluginRoutes.get('/:id', async (c) => { isCore: plugin.is_core, settings: enrichedSettings } - + // Map activity data const templateActivity = (activity || []).map(item => ({ id: item.id, @@ -342,7 +210,7 @@ adminPluginRoutes.get('/:id', async (c) => { timestamp: item.timestamp, user: item.user_email })) - + const pageData: PluginSettingsPageData = { plugin: templatePlugin, activity: templateActivity, @@ -352,7 +220,7 @@ adminPluginRoutes.get('/:id', async (c) => { role: user?.role || 'user' } } - + return c.html(renderPluginSettingsPage(pageData)) } catch (error) { console.error('Error getting plugin settings page:', error) @@ -366,15 +234,15 @@ adminPluginRoutes.post('/:id/activate', async (c) => { const user = c.get('user') const db = c.env.DB const pluginId = c.req.param('id') - + // Temporarily skip permission check for admin users if (user?.role !== 'admin') { return c.json({ error: 'Access denied' }, 403) } - + const pluginService = new PluginService(db) await pluginService.activatePlugin(pluginId) - + return c.json({ success: true }) } catch (error) { console.error('Error activating plugin:', error) @@ -389,15 +257,15 @@ adminPluginRoutes.post('/:id/deactivate', async (c) => { const user = c.get('user') const db = c.env.DB const pluginId = c.req.param('id') - + // Temporarily skip permission check for admin users if (user?.role !== 'admin') { return c.json({ error: 'Access denied' }, 403) } - + const pluginService = new PluginService(db) await pluginService.deactivatePlugin(pluginId) - + return c.json({ success: true }) } catch (error) { console.error('Error deactivating plugin:', error) @@ -406,378 +274,47 @@ adminPluginRoutes.post('/:id/deactivate', async (c) => { } }) -// Install plugin +// Generic install handler - uses the auto-generated plugin registry. +// No per-plugin switch/case needed. Adding a manifest.json is enough. adminPluginRoutes.post('/install', async (c) => { try { const user = c.get('user') const db = c.env.DB - + // Temporarily skip permission check for admin users if (user?.role !== 'admin') { return c.json({ error: 'Access denied' }, 403) } - + const body = await c.req.json() - const pluginService = new PluginService(db) - - // Handle FAQ plugin installation - if (body.name === 'faq-plugin') { - const faqPlugin = await pluginService.installPlugin({ - id: 'third-party-faq', - name: 'faq-plugin', - display_name: 'FAQ System', - description: 'Frequently Asked Questions management system with categories, search, and custom styling', - version: '2.0.0', - author: 'Community Developer', - category: 'content', - icon: 'ҝ“', - permissions: ['manage:faqs'], - dependencies: [], - settings: { - enableSearch: true, - enableCategories: true, - questionsPerPage: 10 - } - }) - - return c.json({ success: true, plugin: faqPlugin }) - } - - // Handle Demo Login plugin installation - if (body.name === 'demo-login-plugin') { - const demoPlugin = await pluginService.installPlugin({ - id: 'demo-login-prefill', - name: 'demo-login-plugin', - display_name: 'Demo Login Prefill', - description: 'Prefills login form with demo credentials (admin@sonicjs.com/sonicjs!) for easy site demonstration', - version: '1.0.0-beta.1', - author: 'SonicJS', - category: 'demo', - icon: '🎯', - permissions: [], - dependencies: [], - settings: { - enableNotice: true, - demoEmail: 'admin@sonicjs.com', - demoPassword: 'sonicjs!' - } - }) - - return c.json({ success: true, plugin: demoPlugin }) - } - - // Handle core Authentication System plugin installation - if (body.name === 'core-auth') { - const authPlugin = await pluginService.installPlugin({ - id: 'core-auth', - name: 'core-auth', - display_name: 'Authentication System', - description: 'Core authentication and user management system', - version: '1.0.0-beta.1', - author: 'SonicJS Team', - category: 'security', - icon: 'Γ°ΒŸΒ”Β', - permissions: ['manage:users', 'manage:roles', 'manage:permissions'], - dependencies: [], - is_core: true, - settings: {} - }) - - return c.json({ success: true, plugin: authPlugin }) - } - - // Handle core Media Manager plugin installation - if (body.name === 'core-media') { - const mediaPlugin = await pluginService.installPlugin({ - id: 'core-media', - name: 'core-media', - display_name: 'Media Manager', - description: 'Core media upload and management system', - version: '1.0.0-beta.1', - author: 'SonicJS Team', - category: 'media', - icon: 'Γ°ΒŸΒ“ΒΈ', - permissions: ['manage:media', 'upload:files'], - dependencies: [], - is_core: true, - settings: {} - }) - - return c.json({ success: true, plugin: mediaPlugin }) - } - - // Handle core Workflow Engine plugin installation - if (body.name === 'core-workflow') { - const workflowPlugin = await pluginService.installPlugin({ - id: 'core-workflow', - name: 'core-workflow', - display_name: 'Workflow Engine', - description: 'Content workflow and approval system', - version: '1.0.0-beta.1', - author: 'SonicJS Team', - category: 'content', - icon: 'Γ°ΒŸΒ”Β„', - permissions: ['manage:workflows', 'approve:content'], - dependencies: [], - is_core: true, - settings: {} - }) - - return c.json({ success: true, plugin: workflowPlugin }) - } - - // Handle Database Tools plugin installation - if (body.name === 'database-tools') { - const databaseToolsPlugin = await pluginService.installPlugin({ - id: 'database-tools', - name: 'database-tools', - display_name: 'Database Tools', - description: 'Database management tools including truncate, backup, and validation', - version: '1.0.0-beta.1', - author: 'SonicJS Team', - category: 'system', - icon: 'Γ°ΒŸΒ—Β„Γ―ΒΈΒ', - permissions: ['manage:database', 'admin'], - dependencies: [], - is_core: false, - settings: { - enableTruncate: true, - enableBackup: true, - enableValidation: true, - requireConfirmation: true - } - }) - - return c.json({ success: true, plugin: databaseToolsPlugin }) - } - // Handle Seed Data plugin installation - if (body.name === 'seed-data') { - const seedDataPlugin = await pluginService.installPlugin({ - id: 'seed-data', - name: 'seed-data', - display_name: 'Seed Data', - description: 'Generate realistic example users and content for testing and development', - version: '1.0.0-beta.1', - author: 'SonicJS Team', - category: 'development', - icon: '🌱', - permissions: ['admin'], - dependencies: [], - is_core: false, - settings: { - userCount: 20, - contentCount: 200, - defaultPassword: 'password123' - } - }) + // Look up plugin in registry by codeName (what the frontend sends as body.name) + // or by id + const registryEntry = findPluginByCodeName(body.name) + || PLUGIN_REGISTRY[body.name] + || PLUGIN_REGISTRY[body.id] - return c.json({ success: true, plugin: seedDataPlugin }) + if (!registryEntry) { + return c.json({ error: 'Plugin not found in registry' }, 404) } - // Handle Quill Editor plugin installation - if (body.name === 'quill-editor') { - const quillPlugin = await pluginService.installPlugin({ - id: 'quill-editor', - name: 'quill-editor', - display_name: 'Quill Rich Text Editor', - description: 'Quill WYSIWYG editor integration for rich text editing. Lightweight, modern editor with customizable toolbars and dark mode support.', - version: '1.0.0', - author: 'SonicJS Team', - category: 'editor', - icon: 'Ҝï¸', - permissions: [], - dependencies: [], - is_core: true, - settings: { - version: '2.0.2', - defaultHeight: 300, - defaultToolbar: 'full', - theme: 'snow' - } - }) - - return c.json({ success: true, plugin: quillPlugin }) - } - - // Handle TinyMCE plugin installation - if (body.name === 'tinymce-plugin') { - const tinymcePlugin = await pluginService.installPlugin({ - id: 'tinymce-plugin', - name: 'tinymce-plugin', - display_name: 'TinyMCE Rich Text Editor', - description: 'Powerful WYSIWYG rich text editor for content creation. Provides a full-featured editor with formatting, media embedding, and customizable toolbars for richtext fields.', - version: '1.0.0', - author: 'SonicJS Team', - category: 'editor', - icon: 'Γ°ΒŸΒ“Β', - permissions: [], - dependencies: [], - is_core: false, - settings: { - apiKey: 'no-api-key', - defaultHeight: 300, - defaultToolbar: 'full', - skin: 'oxide-dark' - } - }) - - return c.json({ success: true, plugin: tinymcePlugin }) - } - - // Handle Easy MDX plugin installation - if (body.name === 'easy-mdx') { - const easyMdxPlugin = await pluginService.installPlugin({ - id: 'easy-mdx', - name: 'easy-mdx', - display_name: 'EasyMDE Markdown Editor', - description: 'Lightweight markdown editor with live preview. Provides a simple and efficient editor with markdown support for richtext fields.', - version: '1.0.0', - author: 'SonicJS Team', - category: 'editor', - icon: 'Γ°ΒŸΒ“Β', - permissions: [], - dependencies: [], - is_core: false, - settings: { - defaultHeight: 400, - theme: 'dark', - toolbar: 'full', - placeholder: 'Start writing your content...' - } - }) - - return c.json({ success: true, plugin: easyMdxPlugin }) - } - - // Handle Security Audit plugin installation - if (body.name === 'security-audit') { - const securityAuditPlugin = await pluginService.installPlugin({ - id: 'security-audit', - name: 'security-audit', - display_name: 'Security Audit', - description: 'Security event logging, brute-force detection, and analytics dashboard. Monitors login attempts, registrations, lockouts, and suspicious activity.', - version: '1.0.0-beta.1', - author: 'SonicJS Team', - category: 'security', - icon: 'Γ°ΒŸΒ›Β‘Γ―ΒΈΒ', - permissions: ['security-audit:view', 'security-audit:manage'], - dependencies: [], - is_core: false, - settings: { - retention: { - daysToKeep: 90, - maxEvents: 100000, - autoPurge: true - }, - bruteForce: { - enabled: true, - maxFailedAttemptsPerIP: 10, - maxFailedAttemptsPerEmail: 5, - windowMinutes: 15, - lockoutDurationMinutes: 30, - alertThreshold: 20 - }, - logging: { - logSuccessfulLogins: true, - logLogouts: true, - logRegistrations: true, - logPasswordResets: true, - logPermissionDenied: true - } - } - }) - - return c.json({ success: true, plugin: securityAuditPlugin }) - } - - // Handle AI Search plugin installation - if (body.name === 'ai-search-plugin' || body.name === 'ai-search') { - const defaultSettings = { - enabled: true, - ai_mode_enabled: true, - selected_collections: [], - dismissed_collections: [], - autocomplete_enabled: true, - cache_duration: 1, - results_limit: 20, - index_media: false, - } - - const aiSearchPlugin = await pluginService.installPlugin({ - id: 'ai-search', - name: 'ai-search-plugin', - display_name: 'AI Search', - description: 'Advanced search with Cloudflare AI Search. Full-text search, semantic search, and advanced filtering across all content collections.', - version: '1.0.0', - author: 'SonicJS Team', - category: 'search', - icon: 'Γ°ΒŸΒ”Β', - permissions: [], - dependencies: [], - is_core: true, - settings: defaultSettings - }) - - return c.json({ success: true, plugin: aiSearchPlugin }) - } - - // Handle Turnstile plugin installation - if (body.name === 'turnstile-plugin') { - const turnstilePlugin = await pluginService.installPlugin({ - id: 'turnstile', - name: 'turnstile-plugin', - display_name: 'Cloudflare Turnstile', - description: 'CAPTCHA-free bot protection for forms using Cloudflare Turnstile. Provides seamless spam prevention with configurable modes, themes, and pre-clearance options.', - version: '1.0.0', - author: 'SonicJS Team', - category: 'security', - icon: 'Γ°ΒŸΒ›Β‘Γ―ΒΈΒ', - permissions: [], - dependencies: [], - is_core: true, - settings: { - siteKey: '', - secretKey: '', - theme: 'auto', - size: 'normal', - mode: 'managed', - appearance: 'always', - preClearanceEnabled: false, - preClearanceLevel: 'managed', - enabled: false - } - }) - - return c.json({ success: true, plugin: turnstilePlugin }) - } - - // Handle Form Builder plugin installation - if (body.name === 'form-builder') { - const formBuilderPlugin = await pluginService.installPlugin({ - id: 'form-builder', - name: 'form-builder', - display_name: 'Form Builder', - description: 'Drag-and-drop form builder with conditional logic, file uploads, and email notifications. Create contact forms, surveys, and data collection forms.', - version: '1.0.0', - author: 'SonicJS Team', - category: 'content', - icon: '\u{1F4DD}', - permissions: ['forms:create', 'forms:manage', 'forms:submissions'], - dependencies: [], - settings: { - enableNotifications: true, - enableFileUploads: true, - maxSubmissionsPerForm: 0, - submissionRetentionDays: 90 - } - }) - - return c.json({ success: true, plugin: formBuilderPlugin }) - } - - return c.json({ error: 'Plugin not found in registry' }, 404) + const plugin = await pluginService.installPlugin({ + id: registryEntry.id, + name: registryEntry.codeName, + display_name: registryEntry.displayName, + description: registryEntry.description, + version: registryEntry.version, + author: registryEntry.author, + category: registryEntry.category, + icon: registryEntry.iconEmoji, + permissions: registryEntry.permissions, + dependencies: registryEntry.dependencies, + is_core: registryEntry.is_core, + settings: registryEntry.defaultSettings, + }) + + return c.json({ success: true, plugin }) } catch (error) { console.error('Error installing plugin:', error) const message = error instanceof Error ? error.message : 'Failed to install plugin' @@ -791,15 +328,15 @@ adminPluginRoutes.post('/:id/uninstall', async (c) => { const user = c.get('user') const db = c.env.DB const pluginId = c.req.param('id') - + // Temporarily skip permission check for admin users if (user?.role !== 'admin') { return c.json({ error: 'Access denied' }, 403) } - + const pluginService = new PluginService(db) await pluginService.uninstallPlugin(pluginId) - + return c.json({ success: true }) } catch (error) { console.error('Error uninstalling plugin:', error) diff --git a/packages/core/src/services/plugin-bootstrap.ts b/packages/core/src/services/plugin-bootstrap.ts index 09be6f33d..2310f1082 100644 --- a/packages/core/src/services/plugin-bootstrap.ts +++ b/packages/core/src/services/plugin-bootstrap.ts @@ -1,5 +1,7 @@ import type { D1Database } from "@cloudflare/workers-types"; import { PluginService } from "./plugin-service"; +import { PLUGIN_REGISTRY } from "../plugins/manifest-registry"; +import type { PluginRegistryEntry } from "../plugins/manifest-registry"; export interface CorePlugin { id: string; @@ -15,6 +17,46 @@ export interface CorePlugin { settings?: any; } +/** + * Build the CORE_PLUGINS list from the auto-generated registry. + * To add a new bootstrapped plugin, create a manifest.json and + * run: node packages/scripts/generate-plugin-registry.mjs + * + * Only plugins that are in the BOOTSTRAP_PLUGIN_IDS list will be + * auto-installed on first boot. Edit this list to control which + * plugins are bootstrapped. + */ +const BOOTSTRAP_PLUGIN_IDS = [ + "core-auth", + "core-media", + "database-tools", + "seed-data", + "core-cache", + "workflow-plugin", + "easy-mdx", + "ai-search", + "oauth-providers", + "global-variables", + "user-profiles", + "stripe", +]; + +function registryToCorePlugin(entry: PluginRegistryEntry): CorePlugin { + return { + id: entry.id, + name: entry.codeName, + display_name: entry.displayName, + description: entry.description, + version: entry.version, + author: entry.author, + category: entry.category, + icon: entry.iconEmoji, + permissions: entry.permissions, + dependencies: entry.dependencies, + settings: entry.defaultSettings, + }; +} + export class PluginBootstrapService { private pluginService: PluginService; @@ -23,222 +65,12 @@ export class PluginBootstrapService { } /** - * Core plugins that should always be available in the system + * Core plugins derived from the auto-generated plugin registry. + * Only plugins listed in BOOTSTRAP_PLUGIN_IDS are included. */ - private readonly CORE_PLUGINS: CorePlugin[] = [ - { - id: "core-auth", - name: "core-auth", - display_name: "Authentication System", - description: "Core authentication and user management system", - version: "1.0.0", - author: "SonicJS Team", - category: "security", - icon: "πŸ”", - permissions: ["manage:users", "manage:roles", "manage:permissions"], - dependencies: [], - settings: { - requiredFields: { - email: { required: true, minLength: 5, label: "Email", type: "email" }, - password: { required: true, minLength: 8, label: "Password", type: "password" }, - username: { required: true, minLength: 3, label: "Username", type: "text" }, - firstName: { required: true, minLength: 1, label: "First Name", type: "text" }, - lastName: { required: true, minLength: 1, label: "Last Name", type: "text" }, - }, - validation: { - emailFormat: true, - allowDuplicateUsernames: false, - passwordRequirements: { - requireUppercase: false, - requireLowercase: false, - requireNumbers: false, - requireSpecialChars: false, - }, - }, - registration: { - enabled: true, - requireEmailVerification: false, - defaultRole: "viewer", - }, - }, - }, - { - id: "core-media", - name: "core-media", - display_name: "Media Manager", - description: "Core media upload and management system", - version: "1.0.0", - author: "SonicJS Team", - category: "media", - icon: "πŸ“Έ", - permissions: ["manage:media", "upload:files"], - dependencies: [], - settings: {}, - }, - { - id: "database-tools", - name: "database-tools", - display_name: "Database Tools", - description: - "Database management tools including truncate, backup, and validation", - version: "1.0.0", - author: "SonicJS Team", - category: "system", - icon: "πŸ—„οΈ", - permissions: ["manage:database", "admin"], - dependencies: [], - settings: { - enableTruncate: true, - enableBackup: true, - enableValidation: true, - requireConfirmation: true, - }, - }, - { - id: "seed-data", - name: "seed-data", - display_name: "Seed Data", - description: - "Generate realistic example users and content for testing and development", - version: "1.0.0", - author: "SonicJS Team", - category: "development", - icon: "🌱", - permissions: ["admin"], - dependencies: [], - settings: { - userCount: 20, - contentCount: 200, - defaultPassword: "password123", - }, - }, - { - id: "core-cache", - name: "core-cache", - display_name: "Cache System", - description: - "Three-tiered caching system with memory, KV, and database layers", - version: "1.0.0", - author: "SonicJS Team", - category: "performance", - icon: "⚑", - permissions: ["manage:cache", "view:stats"], - dependencies: [], - settings: { - enableMemoryCache: true, - enableKVCache: true, - enableDatabaseCache: true, - defaultTTL: 3600, - }, - }, - { - id: "workflow-plugin", - name: "workflow-plugin", - display_name: "Workflow Management", - description: - "Content workflow management with approval chains, scheduling, and automation", - version: "1.0.0-beta.1", - author: "SonicJS Team", - category: "content", - icon: "πŸ”„", - permissions: ["manage:workflows", "view:workflows", "transition:content"], - dependencies: ["content-plugin"], - settings: { - enableApprovalChains: true, - enableScheduling: true, - enableAutomation: true, - enableNotifications: true, - }, - }, - { - id: "easy-mdx", - name: "easy-mdx", - display_name: "EasyMDE Editor", - description: "Lightweight markdown editor with live preview for richtext fields", - version: "1.0.0", - author: "SonicJS Team", - category: "editor", - icon: "✍️", - permissions: [], - dependencies: [], - settings: { - defaultHeight: 400, - toolbar: "full", - placeholder: "Start writing your content...", - }, - }, - { - id: "ai-search", - name: "ai-search-plugin", - display_name: "AI Search", - description: "Advanced search with Cloudflare AI Search. Full-text search, semantic search, and advanced filtering across all content collections.", - version: "1.0.0", - author: "SonicJS Team", - category: "search", - icon: "πŸ”", - permissions: ["settings:write", "admin:access", "content:read"], - dependencies: [], - settings: { - enabled: false, - ai_mode_enabled: true, - selected_collections: [], - dismissed_collections: [], - autocomplete_enabled: true, - cache_duration: 1, - results_limit: 20, - index_media: false, - }, - }, - { - id: "oauth-providers", - name: "oauth-providers", - display_name: "OAuth Providers", - description: "OAuth2/OIDC social login with GitHub, Google, and more", - version: "1.0.0-beta.1", - author: "SonicJS Team", - category: "authentication", - icon: "πŸ”‘", - permissions: ["oauth:manage"], - dependencies: [], - settings: { - providers: { - github: { clientId: "", clientSecret: "", enabled: false }, - google: { clientId: "", clientSecret: "", enabled: false }, - }, - }, - }, - { - id: "global-variables", - name: "global-variables", - display_name: "Global Variables", - description: - "Dynamic content variables with inline token support. Use {variable_key} syntax in rich text fields for server-side resolution.", - version: "1.0.0-beta.1", - author: "SonicJS Team", - category: "content", - icon: "πŸ”€", - permissions: ["global-variables:manage", "global-variables:view"], - dependencies: [], - settings: { - enableResolution: true, - cacheEnabled: true, - cacheTTL: 300, - }, - }, - { - id: "user-profiles", - name: "user-profiles", - display_name: "User Profiles", - description: "Configurable custom profile fields for users", - version: "1.0.0-beta.1", - author: "SonicJS Team", - category: "users", - icon: "πŸ‘€", - permissions: ["user-profiles:manage"], - dependencies: [], - settings: {}, - }, - ]; + private readonly CORE_PLUGINS: CorePlugin[] = BOOTSTRAP_PLUGIN_IDS + .filter((id) => PLUGIN_REGISTRY[id] !== undefined) + .map((id) => registryToCorePlugin(PLUGIN_REGISTRY[id]!)); /** * Bootstrap all core plugins - install them if they don't exist @@ -291,8 +123,6 @@ export class PluginBootstrapService { } // Only auto-activate on first install, respect user's activation state on subsequent boots - // This preserves the activation state across server restarts - // Core plugins (with core- prefix) are activated on first install in the else block below } else { // Install the plugin console.log( @@ -303,13 +133,11 @@ export class PluginBootstrapService { is_core: plugin.name.startsWith("core-"), }); - // Activate core plugins immediately after installation - if (plugin.name.startsWith("core-")) { - console.log( - `[PluginBootstrap] Activating newly installed core plugin: ${plugin.display_name}` - ); - await this.pluginService.activatePlugin(plugin.id); - } + // Activate plugins immediately after installation + console.log( + `[PluginBootstrap] Activating newly installed plugin: ${plugin.display_name}` + ); + await this.pluginService.activatePlugin(plugin.id); } } catch (error) { console.error( @@ -327,8 +155,8 @@ export class PluginBootstrapService { const now = Math.floor(Date.now() / 1000); const stmt = this.db.prepare(` - UPDATE plugins - SET + UPDATE plugins + SET version = ?, description = ?, permissions = ?, diff --git a/packages/scripts/generate-plugin-registry.mjs b/packages/scripts/generate-plugin-registry.mjs index 820ef58fb..d0b79524c 100644 --- a/packages/scripts/generate-plugin-registry.mjs +++ b/packages/scripts/generate-plugin-registry.mjs @@ -2,43 +2,30 @@ /** * Plugin Registry Generator * - * This script scans the src/plugins directory for all manifest.json files - * and generates a TypeScript file (plugin-registry.ts) that exports all - * plugin manifests as a typed registry. + * Scans src/plugins/ for manifest.json files and generates a TypeScript + * registry (plugin-registry.ts) that is the single source of truth for + * all plugin metadata. This eliminates hardcoded plugin lists throughout + * the codebase. * - * This allows us to: - * 1. Have a single source of truth (manifest.json files) - * 2. Auto-discover plugins at build time - * 3. Work with Cloudflare Workers (no runtime filesystem access) - * - * Run: node packages/scripts/generate-plugin-registry.mjs (or npm run plugins:generate) + * Run: node packages/scripts/generate-plugin-registry.mjs */ import { readdir, readFile, writeFile } from 'fs/promises' import { join, dirname } from 'path' import { fileURLToPath } from 'url' -const __filename = fileURLToPath(import.meta.url) -const __dirname = dirname(__filename) -const PROJECT_ROOT = join(__dirname, '..', '..') -const PLUGINS_DIR = join(PROJECT_ROOT, 'src/plugins') -const OUTPUT_FILE = join(PROJECT_ROOT, 'src/plugins/plugin-registry.ts') +const __dirname = dirname(fileURLToPath(import.meta.url)) +const PROJECT_ROOT = join(__dirname, '..', 'core') +const PLUGINS_DIR = join(PROJECT_ROOT, 'src', 'plugins') +const OUTPUT_FILE = join(PROJECT_ROOT, 'src', 'plugins', 'manifest-registry.ts') -/** - * Recursively find all manifest.json files - */ async function findManifests(dir, manifests = []) { try { const entries = await readdir(dir, { withFileTypes: true }) - for (const entry of entries) { const fullPath = join(dir, entry.name) - - if (entry.isDirectory()) { - // Skip node_modules and hidden directories - if (!entry.name.startsWith('.') && entry.name !== 'node_modules') { - await findManifests(fullPath, manifests) - } + if (entry.isDirectory() && !entry.name.startsWith('.') && entry.name !== 'node_modules') { + await findManifests(fullPath, manifests) } else if (entry.name === 'manifest.json') { manifests.push(fullPath) } @@ -46,177 +33,195 @@ async function findManifests(dir, manifests = []) { } catch (error) { console.error(`Error reading directory ${dir}:`, error.message) } - return manifests } -/** - * Load and validate a manifest file - */ async function loadManifest(manifestPath) { try { const content = await readFile(manifestPath, 'utf-8') const manifest = JSON.parse(content) - - // Validate required fields - if (!manifest.id) { - console.warn(`⚠️ Manifest at ${manifestPath} is missing 'id' field`) + const id = manifest.id || manifest.name + if (!id || typeof id !== 'string') { + console.warn(` SKIP ${manifestPath} (no id)`) return null } - - return { - path: manifestPath, - manifest - } + return { path: manifestPath, manifest, id } } catch (error) { - console.error(`❌ Error loading manifest ${manifestPath}:`, error.message) + console.error(` ERROR ${manifestPath}:`, error.message) return null } } /** - * Generate TypeScript plugin registry file + * Normalize a manifest into the canonical registry format */ -function generateRegistryFile(manifests) { +function normalizeManifest(manifest) { + const id = manifest.id || manifest.name + const codeName = manifest.codeName || id + const displayName = manifest.displayName || manifest.name || id + const iconEmoji = manifest.iconEmoji || '' + const is_core = manifest.is_core ?? manifest.isCore ?? manifest.core ?? false + const defaultSettings = manifest.defaultSettings || {} + + // Normalize author (some manifests use object { name, email }) + let author = 'Unknown' + if (typeof manifest.author === 'string') { + author = manifest.author + } else if (manifest.author && typeof manifest.author === 'object' && manifest.author.name) { + author = manifest.author.name + } + + // Normalize permissions to array of strings + let permissions = [] + if (Array.isArray(manifest.permissions)) { + permissions = manifest.permissions + } else if (manifest.permissions && typeof manifest.permissions === 'object') { + permissions = Object.keys(manifest.permissions) + } + + // Normalize adminMenu (some manifests use href instead of path) + let adminMenu = null + if (manifest.adminMenu) { + const raw = manifest.adminMenu + adminMenu = { + label: raw.label || displayName, + icon: raw.icon || '', + path: raw.path || raw.href || '', + order: raw.order || 0, + } + } + + return { + id, + codeName, + displayName, + description: manifest.description || '', + version: manifest.version || '1.0.0', + author, + category: manifest.category || 'general', + iconEmoji, + is_core, + permissions, + dependencies: manifest.dependencies || [], + defaultSettings, + adminMenu, + } +} + +function generateRegistryFile(entries) { const timestamp = new Date().toISOString() - // Generate the registry object - const registryEntries = manifests - .map(({ manifest }) => { - const id = manifest.id - const manifestJson = JSON.stringify(manifest, null, 2) + const registryEntries = entries + .map(({ normalized }) => { + const json = JSON.stringify(normalized, null, 2) .split('\n') - .map((line, i) => i === 0 ? line : ` ${line}`) + .map((line, i) => (i === 0 ? line : ` ${line}`)) .join('\n') - - return ` '${id}': ${manifestJson}` + return ` '${normalized.id}': ${json}` }) .join(',\n\n') - // Generate TypeScript file return `/** - * Plugin Registry - * - * This file is AUTO-GENERATED by packages/scripts/generate-plugin-registry.mjs - * DO NOT EDIT MANUALLY - your changes will be overwritten! + * Plugin Registry - AUTO-GENERATED * - * Generated: ${timestamp} + * Generated by: packages/scripts/generate-plugin-registry.mjs + * Generated at: ${timestamp} * Source: All manifest.json files in src/plugins/ + * + * DO NOT EDIT MANUALLY - run the generator script instead. + * To add a new plugin, create a manifest.json in the plugin directory. */ -export interface PluginManifest { +export interface PluginRegistryEntry { id: string - name: string - version: string + codeName: string + displayName: string description: string + version: string author: string - homepage?: string - repository?: string - license?: string category: string - tags?: string[] - dependencies?: string[] - settings?: Record - hooks?: Record - routes?: Array<{ - path: string - method: string - handler: string - description?: string - }> - permissions?: Record - adminMenu?: { + iconEmoji: string + is_core: boolean + permissions: string[] + dependencies: string[] + defaultSettings: Record + adminMenu: { label: string icon: string path: string order: number - } + } | null } /** - * Plugin Registry - * Maps plugin ID to its manifest + * All discovered plugins, keyed by plugin ID. */ -export const PLUGIN_REGISTRY: Record = { +export const PLUGIN_REGISTRY: Record = { ${registryEntries} } as const /** - * Get all plugin IDs + * All plugin IDs. */ -export const PLUGIN_IDS = Object.keys(PLUGIN_REGISTRY) as ReadonlyArray +export const ALL_PLUGIN_IDS = Object.keys(PLUGIN_REGISTRY) /** - * Get all core plugin IDs (plugins with is_core flag or in core directories) + * Plugins that have their own admin page (have an adminMenu entry). */ -export const CORE_PLUGIN_IDS = PLUGIN_IDS.filter(id => { - const manifest = PLUGIN_REGISTRY[id] - return id.startsWith('core-') || id === 'design' || id === 'database-tools' || id === 'hello-world' -}) as ReadonlyArray +export const PLUGINS_WITH_ADMIN_PAGES = ALL_PLUGIN_IDS.filter( + id => PLUGIN_REGISTRY[id]?.adminMenu !== null +) /** - * Get plugin manifest by ID + * Look up a plugin by its codeName (the \`name\` field stored in the DB). + * Falls back to id lookup if no codeName match. */ -export function getPluginManifest(id: string): PluginManifest | undefined { - return PLUGIN_REGISTRY[id] +export function findPluginByCodeName(codeName: string): PluginRegistryEntry | undefined { + return Object.values(PLUGIN_REGISTRY).find(p => p.codeName === codeName) + || PLUGIN_REGISTRY[codeName] } /** - * Check if a plugin exists + * Get a plugin by ID. */ -export function hasPlugin(id: string): boolean { - return id in PLUGIN_REGISTRY -} - -/** - * Get all plugins by category - */ -export function getPluginsByCategory(category: string): PluginManifest[] { - return PLUGIN_IDS - .map(id => PLUGIN_REGISTRY[id]) - .filter((manifest): manifest is PluginManifest => manifest !== undefined && manifest.category === category) +export function getPlugin(id: string): PluginRegistryEntry | undefined { + return PLUGIN_REGISTRY[id] } ` } -/** - * Main execution - */ async function main() { - console.log('πŸ” Discovering plugins...\n') + console.log('Discovering plugins...\n') - // Find all manifest.json files const manifestPaths = await findManifests(PLUGINS_DIR) - console.log(`Found ${manifestPaths.length} manifest.json files:\n`) + console.log(`Found ${manifestPaths.length} manifest.json files\n`) - // Load all manifests - const loadedManifests = [] + const entries = [] for (const path of manifestPaths) { const result = await loadManifest(path) if (result) { - loadedManifests.push(result) - console.log(` βœ“ ${result.manifest.id} (${result.manifest.name})`) + const normalized = normalizeManifest(result.manifest) + entries.push({ ...result, normalized }) + console.log(` ${normalized.iconEmoji || ' '} ${normalized.id} (${normalized.displayName})`) } } - console.log(`\nβœ… Loaded ${loadedManifests.length} valid manifests\n`) + // Sort by id for stable output + entries.sort((a, b) => a.normalized.id.localeCompare(b.normalized.id)) - // Generate registry file - const registryContent = generateRegistryFile(loadedManifests) - await writeFile(OUTPUT_FILE, registryContent, 'utf-8') + console.log(`\nLoaded ${entries.length} plugins\n`) - console.log(`πŸ“ Generated plugin registry: ${OUTPUT_FILE}`) - console.log(`\n✨ Plugin registry successfully generated!\n`) + const content = generateRegistryFile(entries) + await writeFile(OUTPUT_FILE, content, 'utf-8') - // Print summary - console.log('Summary:') - console.log(` - Total plugins: ${loadedManifests.length}`) - console.log(` - Registry file: src/plugins/plugin-registry.ts`) - console.log(` - Core plugins: ${loadedManifests.filter(m => m.manifest.id.startsWith('core-')).length}`) + console.log(`Generated: src/plugins/plugin-registry.ts`) + console.log(` Total: ${entries.length}`) + console.log(` Core: ${entries.filter(e => e.normalized.is_core).length}`) + console.log(` With admin pages: ${entries.filter(e => e.normalized.adminMenu).length}`) console.log('') } main().catch(error => { - console.error('❌ Fatal error:', error) + console.error('Fatal error:', error) process.exit(1) }) From c05adc58ae14f61f2f082ae52369faf130c26f6f Mon Sep 17 00:00:00 2001 From: Lane Campbell Date: Thu, 9 Apr 2026 18:05:46 -0700 Subject: [PATCH 2/3] fix: update admin-plugins tests for registry-based install handler - Mock manifest-registry in tests so test plugin names are recognized - Move auth check before PLUGINS_WITH_ADMIN_PAGES check in /:id route Co-Authored-By: Claude Opus 4.6 --- .../__tests__/routes/admin-plugins.test.ts | 44 +++++++++++++++++++ packages/core/src/routes/admin-plugins.ts | 10 ++--- 2 files changed, 49 insertions(+), 5 deletions(-) diff --git a/packages/core/src/__tests__/routes/admin-plugins.test.ts b/packages/core/src/__tests__/routes/admin-plugins.test.ts index c10cdee36..9df7abdd1 100644 --- a/packages/core/src/__tests__/routes/admin-plugins.test.ts +++ b/packages/core/src/__tests__/routes/admin-plugins.test.ts @@ -73,6 +73,50 @@ vi.mock('../../middleware', () => ({ } })) +// Mock the manifest registry so test plugin names are recognized +vi.mock('../../plugins/manifest-registry', () => { + const makeEntry = (id: string, codeName?: string, adminMenu?: any) => ({ + id, + codeName: codeName || id, + displayName: id, + description: `Test plugin ${id}`, + version: '1.0.0', + author: 'Test', + category: 'general', + iconEmoji: '', + is_core: false, + permissions: [], + dependencies: [], + defaultSettings: {}, + adminMenu: adminMenu || null, + }) + + const PLUGIN_REGISTRY: Record = { + 'email': makeEntry('email', 'email'), + 'auth': makeEntry('auth', 'auth'), + 'faq-plugin': makeEntry('faq-plugin', 'faq-plugin'), + 'demo-login-plugin': makeEntry('demo-login-plugin', 'demo-login-plugin'), + 'core-auth': makeEntry('core-auth', 'core-auth'), + 'core-media': makeEntry('core-media', 'core-media'), + 'core-workflow': makeEntry('core-workflow', 'core-workflow'), + 'database-tools': makeEntry('database-tools', 'database-tools'), + 'seed-data': makeEntry('seed-data', 'seed-data'), + 'quill-editor': makeEntry('quill-editor', 'quill-editor'), + 'tinymce-plugin': makeEntry('tinymce-plugin', 'tinymce-plugin'), + 'easy-mdx': makeEntry('easy-mdx', 'easy-mdx'), + 'turnstile-plugin': makeEntry('turnstile-plugin', 'turnstile-plugin'), + } + + return { + PLUGIN_REGISTRY, + ALL_PLUGIN_IDS: Object.keys(PLUGIN_REGISTRY), + PLUGINS_WITH_ADMIN_PAGES: [], + findPluginByCodeName: (codeName: string) => + Object.values(PLUGIN_REGISTRY).find((p: any) => p.codeName === codeName) || PLUGIN_REGISTRY[codeName], + getPlugin: (id: string) => PLUGIN_REGISTRY[id], + } +}) + // Mock the PluginService as a class vi.mock('../../services', () => ({ PluginService: class MockPluginService { diff --git a/packages/core/src/routes/admin-plugins.ts b/packages/core/src/routes/admin-plugins.ts index f0f71f3e5..ab720d03e 100644 --- a/packages/core/src/routes/admin-plugins.ts +++ b/packages/core/src/routes/admin-plugins.ts @@ -131,17 +131,17 @@ adminPluginRoutes.get('/:id', async (c) => { const db = c.env.DB const pluginId = c.req.param('id') + // Check authorization first + if (user?.role !== 'admin') { + return c.redirect('/admin/plugins') + } + // Skip plugins that have their own custom admin pages (detected from registry adminMenu) if (PLUGINS_WITH_ADMIN_PAGES.includes(pluginId)) { // Let the plugin's own route handle this return c.text('', 404) // Return 404 so Hono continues to next route } - // Check authorization - if (user?.role !== 'admin') { - return c.redirect('/admin/plugins') - } - const pluginService = new PluginService(db) const plugin = await pluginService.getPlugin(pluginId) From 7704ae7ec3e4a00103b1518e26e347bde46e70f9 Mon Sep 17 00:00:00 2001 From: Lane Campbell Date: Thu, 9 Apr 2026 18:37:09 -0700 Subject: [PATCH 3/3] fix: remove PLUGINS_WITH_ADMIN_PAGES check from /:id route Plugins with custom admin routes (e.g., stripe) are already registered before the catch-all in app.ts, so the check was redundant and caused plugins like turnstile that use the generic settings page to 404. Co-Authored-By: Claude Opus 4.6 --- packages/core/src/routes/admin-plugins.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/packages/core/src/routes/admin-plugins.ts b/packages/core/src/routes/admin-plugins.ts index ab720d03e..1b6795a1b 100644 --- a/packages/core/src/routes/admin-plugins.ts +++ b/packages/core/src/routes/admin-plugins.ts @@ -4,7 +4,7 @@ import { renderPluginsListPage, PluginsListPageData, Plugin } from '../templates import { renderPluginSettingsPage, PluginSettingsPageData } from '../templates/pages/admin-plugin-settings.template' import { SettingsService } from '../services/settings' import { PluginService } from '../services' -import { PLUGIN_REGISTRY, PLUGINS_WITH_ADMIN_PAGES, findPluginByCodeName } from '../plugins/manifest-registry' +import { PLUGIN_REGISTRY, findPluginByCodeName } from '../plugins/manifest-registry' import type { Bindings, Variables } from '../app' const adminPluginRoutes = new Hono<{ Bindings: Bindings; Variables: Variables }>() @@ -136,12 +136,6 @@ adminPluginRoutes.get('/:id', async (c) => { return c.redirect('/admin/plugins') } - // Skip plugins that have their own custom admin pages (detected from registry adminMenu) - if (PLUGINS_WITH_ADMIN_PAGES.includes(pluginId)) { - // Let the plugin's own route handle this - return c.text('', 404) // Return 404 so Hono continues to next route - } - const pluginService = new PluginService(db) const plugin = await pluginService.getPlugin(pluginId)
UserStatusPrice IDCurrent PeriodCancel at EndStripeUserStatusPrice IDCurrent PeriodCancel at EndStripe
No subscriptions found
No subscriptions found
-
${sub.userEmail || sub.userId}
-
${sub.stripeCustomerId}
+
${sub.userEmail || sub.userId}
+
${sub.stripeCustomerId}
${statusBadge(sub.status)}${sub.stripePriceId} + ${sub.stripePriceId} ${formatDate(sub.currentPeriodStart)} - ${formatDate(sub.currentPeriodEnd)} ${sub.cancelAtPeriodEnd - ? 'Yes' - : 'No' + ? 'Yes' + : 'No' } + class="text-cyan-600 dark:text-cyan-400 hover:text-cyan-500 dark:hover:text-cyan-300"> View in Stripe