Barazo AppView backend barazo.forum
at main 780 lines 26 kB view raw
1import { eq } from 'drizzle-orm' 2import { execFile } from 'node:child_process' 3import { readFile } from 'node:fs/promises' 4import { createRequire } from 'node:module' 5import { promisify } from 'node:util' 6import type { FastifyPluginCallback } from 'fastify' 7import { notFound, badRequest, conflict, errorResponseSchema } from '../lib/api-errors.js' 8import { 9 getRegistryIndex, 10 searchRegistryPlugins, 11 getFeaturedPlugins, 12} from '../lib/plugins/registry.js' 13import { executeHook, buildLoadedPlugin } from '../lib/plugins/runtime.js' 14import { createPluginContext } from '../lib/plugins/context.js' 15import { updatePluginSettingsSchema, installPluginSchema } from '../validation/admin-plugins.js' 16import { pluginManifestSchema } from '../validation/plugin-manifest.js' 17import { plugins, pluginSettings } from '../db/schema/plugins.js' 18 19const execFileAsync = promisify(execFile) 20 21// --------------------------------------------------------------------------- 22// OpenAPI JSON Schema definitions 23// --------------------------------------------------------------------------- 24 25const pluginJsonSchema = { 26 type: 'object' as const, 27 properties: { 28 id: { type: 'string' as const }, 29 name: { type: 'string' as const }, 30 displayName: { type: 'string' as const }, 31 version: { type: 'string' as const }, 32 description: { type: 'string' as const }, 33 source: { type: 'string' as const }, 34 category: { type: 'string' as const }, 35 enabled: { type: 'boolean' as const }, 36 manifestJson: { type: 'object' as const, additionalProperties: true }, 37 dependencies: { type: 'array' as const, items: { type: 'string' as const } }, 38 settingsSchema: { type: 'object' as const, additionalProperties: true }, 39 settings: { type: 'object' as const, additionalProperties: true }, 40 installedAt: { type: 'string' as const, format: 'date-time' as const }, 41 updatedAt: { type: 'string' as const, format: 'date-time' as const }, 42 }, 43} 44 45const pluginListJsonSchema = { 46 type: 'object' as const, 47 properties: { 48 plugins: { 49 type: 'array' as const, 50 items: pluginJsonSchema, 51 }, 52 }, 53} 54 55// --------------------------------------------------------------------------- 56// Helpers 57// --------------------------------------------------------------------------- 58 59function serializePlugin(row: typeof plugins.$inferSelect, settings?: Record<string, unknown>) { 60 const manifest = row.manifestJson as ManifestJson | null 61 return { 62 id: row.id, 63 name: row.name, 64 displayName: row.displayName, 65 version: row.version, 66 description: row.description, 67 source: row.source, 68 category: row.category, 69 enabled: row.enabled, 70 manifestJson: row.manifestJson, 71 dependencies: manifest?.dependencies ?? [], 72 settingsSchema: manifest?.settings ?? {}, 73 settings: settings ?? {}, 74 installedAt: row.installedAt.toISOString(), 75 updatedAt: row.updatedAt.toISOString(), 76 } 77} 78 79// --------------------------------------------------------------------------- 80// Manifest type for dependency checking 81// --------------------------------------------------------------------------- 82 83interface ManifestJson { 84 name?: string 85 dependencies?: string[] 86 settings?: Record<string, unknown> 87 [key: string]: unknown 88} 89 90// --------------------------------------------------------------------------- 91// Admin plugin routes 92// --------------------------------------------------------------------------- 93 94/** 95 * Admin plugin management routes for the Barazo forum. 96 * 97 * - GET /api/plugins -- List all plugins with settings 98 * - GET /api/plugins/:id -- Get single plugin 99 * - PATCH /api/plugins/:id/enable -- Enable a plugin 100 * - PATCH /api/plugins/:id/disable -- Disable a plugin 101 * - PATCH /api/plugins/:id/settings -- Update plugin settings 102 * - DELETE /api/plugins/:id -- Uninstall a plugin 103 * - POST /api/plugins/install -- Install from npm 104 */ 105export function adminPluginRoutes(): FastifyPluginCallback { 106 return (app, _opts, done) => { 107 const { db } = app 108 const requireAdmin = app.requireAdmin 109 110 function buildCtxForPlugin(pluginRow: typeof plugins.$inferSelect) { 111 const manifest = pluginRow.manifestJson as { permissions?: { backend?: string[] } } 112 return createPluginContext({ 113 pluginName: pluginRow.name, 114 pluginVersion: pluginRow.version, 115 permissions: manifest.permissions?.backend ?? [], 116 settings: {}, 117 db: app.db, 118 cache: null, 119 oauthClient: null, 120 logger: app.log, 121 communityDid: '', 122 }) 123 } 124 125 // ------------------------------------------------------------------- 126 // GET /api/plugins (admin only) 127 // ------------------------------------------------------------------- 128 129 app.get( 130 '/api/plugins', 131 { 132 preHandler: [requireAdmin], 133 schema: { 134 tags: ['Plugins'], 135 summary: 'List all plugins with their settings', 136 security: [{ bearerAuth: [] }], 137 response: { 138 200: pluginListJsonSchema, 139 401: errorResponseSchema, 140 403: errorResponseSchema, 141 }, 142 }, 143 }, 144 async (_request, reply) => { 145 const allPlugins = await db.select().from(plugins) 146 const allSettings = await db.select().from(pluginSettings) 147 148 // Group settings by pluginId 149 const settingsMap = new Map<string, Record<string, unknown>>() 150 for (const setting of allSettings) { 151 let map = settingsMap.get(setting.pluginId) 152 if (!map) { 153 map = {} 154 settingsMap.set(setting.pluginId, map) 155 } 156 map[setting.key] = setting.value 157 } 158 159 return reply.status(200).send({ 160 plugins: allPlugins.map((p) => serializePlugin(p, settingsMap.get(p.id))), 161 }) 162 } 163 ) 164 165 // ------------------------------------------------------------------- 166 // GET /api/plugins/:id (admin only) 167 // ------------------------------------------------------------------- 168 169 app.get( 170 '/api/plugins/:id', 171 { 172 preHandler: [requireAdmin], 173 schema: { 174 tags: ['Plugins'], 175 summary: 'Get single plugin details', 176 security: [{ bearerAuth: [] }], 177 params: { 178 type: 'object' as const, 179 properties: { 180 id: { type: 'string' as const }, 181 }, 182 required: ['id'], 183 }, 184 response: { 185 200: pluginJsonSchema, 186 401: errorResponseSchema, 187 403: errorResponseSchema, 188 404: errorResponseSchema, 189 }, 190 }, 191 }, 192 async (request, reply) => { 193 const { id } = request.params as { id: string } 194 195 const rows = await db.select().from(plugins).where(eq(plugins.id, id)) 196 197 const plugin = rows[0] 198 if (!plugin) { 199 throw notFound('Plugin not found') 200 } 201 202 const settings = await db 203 .select() 204 .from(pluginSettings) 205 .where(eq(pluginSettings.pluginId, id)) 206 207 const settingsObj: Record<string, unknown> = {} 208 for (const s of settings) { 209 settingsObj[s.key] = s.value 210 } 211 212 return reply.status(200).send(serializePlugin(plugin, settingsObj)) 213 } 214 ) 215 216 // ------------------------------------------------------------------- 217 // PATCH /api/plugins/:id/enable (admin only) 218 // ------------------------------------------------------------------- 219 220 app.patch( 221 '/api/plugins/:id/enable', 222 { 223 preHandler: [requireAdmin], 224 schema: { 225 tags: ['Plugins'], 226 summary: 'Enable a plugin', 227 security: [{ bearerAuth: [] }], 228 params: { 229 type: 'object' as const, 230 properties: { 231 id: { type: 'string' as const }, 232 }, 233 required: ['id'], 234 }, 235 response: { 236 200: pluginJsonSchema, 237 400: errorResponseSchema, 238 401: errorResponseSchema, 239 403: errorResponseSchema, 240 404: errorResponseSchema, 241 }, 242 }, 243 }, 244 async (request, reply) => { 245 const { id } = request.params as { id: string } 246 247 const rows = await db.select().from(plugins).where(eq(plugins.id, id)) 248 249 const plugin = rows[0] 250 if (!plugin) { 251 throw notFound('Plugin not found') 252 } 253 254 if (plugin.enabled) { 255 return reply.status(200).send(serializePlugin(plugin)) 256 } 257 258 // Check dependencies: all declared deps must be enabled 259 const manifest = plugin.manifestJson as ManifestJson 260 const deps = manifest.dependencies ?? [] 261 262 if (deps.length > 0) { 263 const allPlugins = await db.select().from(plugins) 264 const enabledNames = new Set(allPlugins.filter((p) => p.enabled).map((p) => p.name)) 265 const missing = deps.filter((dep) => !enabledNames.has(dep)) 266 267 if (missing.length > 0) { 268 throw badRequest(`Missing required dependencies: ${missing.join(', ')}`) 269 } 270 } 271 272 const updated = await db 273 .update(plugins) 274 .set({ enabled: true, updatedAt: new Date() }) 275 .where(eq(plugins.id, id)) 276 .returning() 277 278 const updatedPlugin = updated[0] 279 if (!updatedPlugin) { 280 throw notFound('Plugin not found after update') 281 } 282 283 // Execute onEnable hook 284 const loaded = app.loadedPlugins.get(plugin.name) 285 if (loaded?.hooks?.onEnable) { 286 const ctx = buildCtxForPlugin(updatedPlugin) 287 // eslint-disable-next-line @typescript-eslint/unbound-method -- plugin hooks are standalone functions 288 const hookFn = loaded.hooks.onEnable as (...args: unknown[]) => Promise<void> 289 await executeHook('onEnable', hookFn, ctx, app.log, plugin.name) 290 } 291 app.enabledPlugins.add(plugin.name) 292 293 app.log.info( 294 { 295 event: 'plugin_enabled', 296 pluginId: id, 297 pluginName: plugin.name, 298 did: request.user?.did, 299 }, 300 'Plugin enabled' 301 ) 302 303 return reply.status(200).send(serializePlugin(updatedPlugin)) 304 } 305 ) 306 307 // ------------------------------------------------------------------- 308 // PATCH /api/plugins/:id/disable (admin only) 309 // ------------------------------------------------------------------- 310 311 app.patch( 312 '/api/plugins/:id/disable', 313 { 314 preHandler: [requireAdmin], 315 schema: { 316 tags: ['Plugins'], 317 summary: 'Disable a plugin', 318 security: [{ bearerAuth: [] }], 319 params: { 320 type: 'object' as const, 321 properties: { 322 id: { type: 'string' as const }, 323 }, 324 required: ['id'], 325 }, 326 response: { 327 200: pluginJsonSchema, 328 401: errorResponseSchema, 329 403: errorResponseSchema, 330 404: errorResponseSchema, 331 409: errorResponseSchema, 332 }, 333 }, 334 }, 335 async (request, reply) => { 336 const { id } = request.params as { id: string } 337 338 const rows = await db.select().from(plugins).where(eq(plugins.id, id)) 339 340 const plugin = rows[0] 341 if (!plugin) { 342 throw notFound('Plugin not found') 343 } 344 345 if (!plugin.enabled) { 346 return reply.status(200).send(serializePlugin(plugin)) 347 } 348 349 // Check no enabled plugins depend on this one 350 const allPlugins = await db.select().from(plugins) 351 const dependents = allPlugins.filter((p) => { 352 if (!p.enabled || p.id === id) return false 353 const manifest = p.manifestJson as ManifestJson 354 const deps = manifest.dependencies ?? [] 355 return deps.includes(plugin.name) 356 }) 357 358 if (dependents.length > 0) { 359 const names = dependents.map((d) => d.name).join(', ') 360 throw conflict( 361 `Cannot disable: the following enabled plugins depend on this one: ${names}` 362 ) 363 } 364 365 const updated = await db 366 .update(plugins) 367 .set({ enabled: false, updatedAt: new Date() }) 368 .where(eq(plugins.id, id)) 369 .returning() 370 371 const updatedPlugin = updated[0] 372 if (!updatedPlugin) { 373 throw notFound('Plugin not found after update') 374 } 375 376 // Execute onDisable hook 377 const loaded = app.loadedPlugins.get(plugin.name) 378 if (loaded?.hooks?.onDisable) { 379 const ctx = buildCtxForPlugin(updatedPlugin) 380 // eslint-disable-next-line @typescript-eslint/unbound-method -- plugin hooks are standalone functions 381 const hookFn = loaded.hooks.onDisable as (...args: unknown[]) => Promise<void> 382 await executeHook('onDisable', hookFn, ctx, app.log, plugin.name) 383 } 384 app.enabledPlugins.delete(plugin.name) 385 386 app.log.info( 387 { 388 event: 'plugin_disabled', 389 pluginId: id, 390 pluginName: plugin.name, 391 did: request.user?.did, 392 }, 393 'Plugin disabled' 394 ) 395 396 return reply.status(200).send(serializePlugin(updatedPlugin)) 397 } 398 ) 399 400 // ------------------------------------------------------------------- 401 // PATCH /api/plugins/:id/settings (admin only) 402 // ------------------------------------------------------------------- 403 404 app.patch( 405 '/api/plugins/:id/settings', 406 { 407 preHandler: [requireAdmin], 408 schema: { 409 tags: ['Plugins'], 410 summary: 'Update plugin settings', 411 security: [{ bearerAuth: [] }], 412 params: { 413 type: 'object' as const, 414 properties: { 415 id: { type: 'string' as const }, 416 }, 417 required: ['id'], 418 }, 419 body: { 420 type: 'object' as const, 421 additionalProperties: true, 422 }, 423 response: { 424 200: { 425 type: 'object' as const, 426 properties: { 427 success: { type: 'boolean' as const }, 428 }, 429 }, 430 400: errorResponseSchema, 431 401: errorResponseSchema, 432 403: errorResponseSchema, 433 404: errorResponseSchema, 434 }, 435 }, 436 }, 437 async (request, reply) => { 438 const { id } = request.params as { id: string } 439 440 const rows = await db.select().from(plugins).where(eq(plugins.id, id)) 441 442 const plugin = rows[0] 443 if (!plugin) { 444 throw notFound('Plugin not found') 445 } 446 447 const parsed = updatePluginSettingsSchema.safeParse(request.body) 448 if (!parsed.success) { 449 throw badRequest('Invalid settings data') 450 } 451 452 const entries = Object.entries(parsed.data) 453 for (const [key, value] of entries) { 454 await db 455 .insert(pluginSettings) 456 .values({ pluginId: id, key, value }) 457 .onConflictDoUpdate({ 458 target: [pluginSettings.pluginId, pluginSettings.key], 459 set: { value }, 460 }) 461 } 462 463 app.log.info( 464 { 465 event: 'plugin_settings_updated', 466 pluginId: id, 467 pluginName: plugin.name, 468 keys: entries.map(([k]) => k), 469 did: request.user?.did, 470 }, 471 'Plugin settings updated' 472 ) 473 474 return reply.status(200).send({ success: true }) 475 } 476 ) 477 478 // ------------------------------------------------------------------- 479 // DELETE /api/plugins/:id (admin only) 480 // ------------------------------------------------------------------- 481 482 app.delete( 483 '/api/plugins/:id', 484 { 485 preHandler: [requireAdmin], 486 schema: { 487 tags: ['Plugins'], 488 summary: 'Uninstall a plugin', 489 security: [{ bearerAuth: [] }], 490 params: { 491 type: 'object' as const, 492 properties: { 493 id: { type: 'string' as const }, 494 }, 495 required: ['id'], 496 }, 497 response: { 498 204: { 499 type: 'null' as const, 500 description: 'Plugin uninstalled successfully', 501 }, 502 401: errorResponseSchema, 503 403: errorResponseSchema, 504 404: errorResponseSchema, 505 409: errorResponseSchema, 506 }, 507 }, 508 }, 509 async (request, reply) => { 510 const { id } = request.params as { id: string } 511 512 const rows = await db.select().from(plugins).where(eq(plugins.id, id)) 513 514 const plugin = rows[0] 515 if (!plugin) { 516 throw notFound('Plugin not found') 517 } 518 519 // Core plugins cannot be uninstalled 520 if (plugin.source === 'core') { 521 throw conflict('Core plugins cannot be uninstalled') 522 } 523 524 // Check no enabled plugins depend on this one 525 const allPlugins = await db.select().from(plugins) 526 const dependents = allPlugins.filter((p) => { 527 if (!p.enabled || p.id === id) return false 528 const manifest = p.manifestJson as ManifestJson 529 const deps = manifest.dependencies ?? [] 530 return deps.includes(plugin.name) 531 }) 532 533 if (dependents.length > 0) { 534 const names = dependents.map((d) => d.name).join(', ') 535 throw conflict( 536 `Cannot uninstall: the following enabled plugins depend on this one: ${names}` 537 ) 538 } 539 540 // Execute onUninstall hook before DB delete 541 const loaded = app.loadedPlugins.get(plugin.name) 542 if (loaded?.hooks?.onUninstall) { 543 const ctx = buildCtxForPlugin(plugin) 544 // eslint-disable-next-line @typescript-eslint/unbound-method -- plugin hooks are standalone functions 545 const hookFn = loaded.hooks.onUninstall as (...args: unknown[]) => Promise<void> 546 await executeHook('onUninstall', hookFn, ctx, app.log, plugin.name) 547 } 548 549 await db.delete(plugins).where(eq(plugins.id, id)) 550 551 app.enabledPlugins.delete(plugin.name) 552 app.loadedPlugins.delete(plugin.name) 553 554 app.log.info( 555 { 556 event: 'plugin_uninstalled', 557 pluginId: id, 558 pluginName: plugin.name, 559 did: request.user?.did, 560 }, 561 'Plugin uninstalled' 562 ) 563 564 return reply.status(204).send() 565 } 566 ) 567 568 // ------------------------------------------------------------------- 569 // POST /api/plugins/install (admin only) 570 // ------------------------------------------------------------------- 571 572 app.post( 573 '/api/plugins/install', 574 { 575 preHandler: [requireAdmin], 576 schema: { 577 tags: ['Plugins'], 578 summary: 'Install a plugin from npm', 579 security: [{ bearerAuth: [] }], 580 body: { 581 type: 'object' as const, 582 properties: { 583 packageName: { type: 'string' as const }, 584 }, 585 required: ['packageName'], 586 }, 587 response: { 588 200: pluginJsonSchema, 589 400: errorResponseSchema, 590 401: errorResponseSchema, 591 403: errorResponseSchema, 592 409: errorResponseSchema, 593 }, 594 }, 595 }, 596 async (request, reply) => { 597 const parsed = installPluginSchema.safeParse(request.body) 598 if (!parsed.success) { 599 throw badRequest('Invalid package name') 600 } 601 602 const { packageName } = parsed.data 603 604 // In SaaS mode, only @barazo scoped packages are allowed 605 if (app.env.HOSTING_MODE === 'saas' && !packageName.startsWith('@barazo/')) { 606 throw badRequest('Only @barazo scoped plugins are allowed in SaaS mode') 607 } 608 609 // Extract bare name (without version specifier) for duplicate check 610 const bareName = packageName.replace(/@[\w.-]+$/, '') 611 612 // Check not already installed 613 const existing = await db.select().from(plugins).where(eq(plugins.name, bareName)) 614 615 if (existing.length > 0) { 616 throw conflict(`Plugin "${bareName}" is already installed`) 617 } 618 619 // Install via npm 620 await execFileAsync('npm', ['install', '--ignore-scripts', packageName]) 621 622 // Read plugin.json from installed package 623 const require = createRequire(import.meta.url) 624 const packageDir = require.resolve(`${bareName}/plugin.json`) 625 const manifestRaw = await readFile(packageDir, 'utf-8') 626 const manifestData: unknown = JSON.parse(manifestRaw) 627 628 const manifestResult = pluginManifestSchema.safeParse(manifestData) 629 if (!manifestResult.success) { 630 throw badRequest('Invalid plugin manifest (plugin.json)') 631 } 632 633 const manifest = manifestResult.data 634 635 // Insert into plugins table (disabled by default) 636 const inserted = await db 637 .insert(plugins) 638 .values({ 639 name: manifest.name, 640 displayName: manifest.displayName, 641 version: manifest.version, 642 description: manifest.description, 643 source: manifest.source, 644 category: manifest.category, 645 enabled: false, 646 manifestJson: manifest, 647 }) 648 .returning() 649 650 const newPlugin = inserted[0] 651 if (!newPlugin) { 652 throw badRequest('Failed to insert plugin') 653 } 654 655 // Load hooks for newly installed plugin and run onInstall 656 const packageDirPath = packageDir.replace(/\/plugin\.json$/, '') 657 const loadedPlugin = await buildLoadedPlugin(manifest, packageDirPath, app.log) 658 app.loadedPlugins.set(manifest.name, loadedPlugin) 659 660 if (loadedPlugin.hooks?.onInstall) { 661 const ctx = buildCtxForPlugin(newPlugin) 662 // eslint-disable-next-line @typescript-eslint/unbound-method -- plugin hooks are standalone functions 663 const hookFn = loadedPlugin.hooks.onInstall as (...args: unknown[]) => Promise<void> 664 await executeHook('onInstall', hookFn, ctx, app.log, manifest.name) 665 } 666 667 app.log.info( 668 { 669 event: 'plugin_installed', 670 pluginId: newPlugin.id, 671 pluginName: newPlugin.name, 672 version: newPlugin.version, 673 did: request.user?.did, 674 }, 675 'Plugin installed' 676 ) 677 678 return reply.status(200).send(serializePlugin(newPlugin)) 679 } 680 ) 681 682 // ------------------------------------------------------------------- 683 // Registry routes (public -- no auth required) 684 // ------------------------------------------------------------------- 685 686 const registryPluginJsonSchema = { 687 type: 'object' as const, 688 properties: { 689 name: { type: 'string' as const }, 690 displayName: { type: 'string' as const }, 691 description: { type: 'string' as const }, 692 version: { type: 'string' as const }, 693 source: { type: 'string' as const }, 694 category: { type: 'string' as const }, 695 barazoVersion: { type: 'string' as const }, 696 author: { 697 type: 'object' as const, 698 properties: { 699 name: { type: 'string' as const }, 700 url: { type: 'string' as const }, 701 }, 702 }, 703 license: { type: 'string' as const }, 704 npmUrl: { type: 'string' as const }, 705 repositoryUrl: { type: 'string' as const }, 706 approved: { type: 'boolean' as const }, 707 featured: { type: 'boolean' as const }, 708 downloads: { type: 'number' as const }, 709 }, 710 } 711 712 const registryPluginListJsonSchema = { 713 type: 'object' as const, 714 properties: { 715 plugins: { 716 type: 'array' as const, 717 items: registryPluginJsonSchema, 718 }, 719 }, 720 } 721 722 // ------------------------------------------------------------------- 723 // GET /api/plugins/registry/search (public) 724 // ------------------------------------------------------------------- 725 726 app.get( 727 '/api/plugins/registry/search', 728 { 729 schema: { 730 tags: ['Plugins'], 731 summary: 'Search the plugin registry', 732 querystring: { 733 type: 'object' as const, 734 properties: { 735 q: { type: 'string' as const }, 736 category: { type: 'string' as const }, 737 source: { type: 'string' as const }, 738 }, 739 }, 740 response: { 741 200: registryPluginListJsonSchema, 742 }, 743 }, 744 }, 745 async (request) => { 746 const { q, category, source } = request.query as { 747 q?: string 748 category?: string 749 source?: string 750 } 751 const registryPlugins = await getRegistryIndex(app) 752 const results = searchRegistryPlugins(registryPlugins, { q, category, source }) 753 return { plugins: results } 754 } 755 ) 756 757 // ------------------------------------------------------------------- 758 // GET /api/plugins/registry/featured (public) 759 // ------------------------------------------------------------------- 760 761 app.get( 762 '/api/plugins/registry/featured', 763 { 764 schema: { 765 tags: ['Plugins'], 766 summary: 'Get featured plugins from the registry', 767 response: { 768 200: registryPluginListJsonSchema, 769 }, 770 }, 771 }, 772 async () => { 773 const registryPlugins = await getRegistryIndex(app) 774 return { plugins: getFeaturedPlugins(registryPlugins) } 775 } 776 ) 777 778 done() 779 } 780}