Barazo AppView backend
barazo.forum
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}