Barazo AppView backend barazo.forum

feat(plugins): wire plugin runtime infrastructure (#94) (#150)

Implement the 4 runtime gaps blocking plugin execution:

1. Lifecycle hook execution - onInstall/onUninstall/onEnable/onDisable
hooks called from admin-plugins routes with proper PluginContext
2. PluginContext construction with ScopedAtProto - public reads via
Bluesky API, authenticated writes via OAuth session restore
3. Plugin route registration - discovered routes mounted at
/api/ext/<short-name>/ with enabled-check preHandler
4. onProfileSync call site - fire-and-forget hook execution after
profile DB update

Also adds runtime.ts module (resolveHookRef, executeHook,
loadPluginHooks, buildLoadedPlugin) and updates ScopedAtProto
interface to take explicit did parameter for write operations.

Closes singi-labs/barazo-workspace#94

authored by

Guido X Jansen and committed by
GitHub
76fafe1e bc1c63e7

+642 -21
+149 -3
src/app.ts
··· 1 + import { join } from 'node:path' 1 2 import Fastify from 'fastify' 2 3 import helmet from '@fastify/helmet' 3 4 import cors from '@fastify/cors' ··· 7 8 import swagger from '@fastify/swagger' 8 9 import scalarApiReference from '@scalar/fastify-api-reference' 9 10 import * as Sentry from '@sentry/node' 10 - import type { FastifyError } from 'fastify' 11 + import type { FastifyError, FastifyPluginCallback } from 'fastify' 11 12 import type { NodeOAuthClient } from '@atproto/oauth-client-node' 12 13 import { sql } from 'drizzle-orm' 13 14 import type { Env } from './config/env.js' ··· 47 48 import { adminDesignRoutes } from './routes/admin-design.js' 48 49 import { adminPluginRoutes } from './routes/admin-plugins.js' 49 50 import { discoverPlugins, syncPluginsToDb, validateAndFilterPlugins } from './lib/plugins/loader.js' 51 + import { buildLoadedPlugin, executeHook, getPluginShortName } from './lib/plugins/runtime.js' 52 + import { createPluginContext, type CacheAdapter } from './lib/plugins/context.js' 53 + import type { PluginContext } from './lib/plugins/types.js' 54 + import type { LoadedPlugin } from './lib/plugins/types.js' 50 55 import { createRequireAdmin } from './auth/require-admin.js' 51 56 import { createRequireOperator } from './auth/require-operator.js' 52 57 import { OzoneService } from './services/ozone.js' ··· 86 91 storage: StorageService 87 92 interactionGraphService: InteractionGraphService 88 93 trustGraphService: TrustGraphService 94 + loadedPlugins: Map<string, LoadedPlugin> 95 + enabledPlugins: Set<string> 89 96 } 90 97 } 91 98 ··· 122 129 // Plugin discovery and DB sync 123 130 const nodeModulesPath = new URL('../node_modules', import.meta.url).pathname 124 131 const discovered = await discoverPlugins(nodeModulesPath, app.log) 132 + const loadedPlugins = new Map<string, LoadedPlugin>() 133 + const enabledPlugins = new Set<string>() 134 + 125 135 if (discovered.length > 0) { 126 136 const validManifests = validateAndFilterPlugins( 127 137 discovered.map((d) => d.manifest), ··· 129 139 app.log 130 140 ) 131 141 app.log.info({ count: validManifests.length }, 'Plugins discovered') 132 - await syncPluginsToDb(discovered, db, app.log) 142 + 143 + const syncResult = await syncPluginsToDb(discovered, db, app.log) 144 + 145 + // Build LoadedPlugin objects (resolve hooks, route paths) 146 + for (const { manifest, packagePath } of discovered) { 147 + const loaded = await buildLoadedPlugin(manifest, packagePath, app.log) 148 + loadedPlugins.set(manifest.name, loaded) 149 + } 150 + 151 + // Run onInstall for newly discovered plugins 152 + for (const newName of syncResult.newPlugins) { 153 + const loaded = loadedPlugins.get(newName) 154 + if (loaded?.hooks?.onInstall) { 155 + const ctx = createPluginContext({ 156 + pluginName: loaded.name, 157 + pluginVersion: loaded.version, 158 + permissions: [], 159 + settings: {}, 160 + db, 161 + cache: null, 162 + oauthClient: null, 163 + logger: app.log, 164 + communityDid: getCommunityDid(env), 165 + }) 166 + // eslint-disable-next-line @typescript-eslint/unbound-method -- plugin hooks are standalone functions 167 + const hookFn = loaded.hooks.onInstall as (...args: unknown[]) => Promise<void> 168 + await executeHook('onInstall', hookFn, ctx, app.log, loaded.name) 169 + } 170 + } 171 + 172 + // Track enabled plugins 173 + const enabledRows = (await db.execute( 174 + sql`SELECT name FROM plugins WHERE enabled = true` 175 + )) as unknown as Array<{ name: string }> 176 + for (const row of enabledRows) { 177 + enabledPlugins.add(row.name) 178 + } 133 179 } else { 134 180 app.log.info('No plugins discovered') 135 181 } 182 + 183 + app.decorate('loadedPlugins', loadedPlugins) 184 + app.decorate('enabledPlugins', enabledPlugins) 136 185 137 186 // Cache 138 187 const cache = createCache(env.VALKEY_URL, app.log) ··· 239 288 const handleResolver = createHandleResolver(cache, db, app.log) 240 289 app.decorate('handleResolver', handleResolver) 241 290 291 + // Wrap Valkey/ioredis client as CacheAdapter for plugin contexts 292 + const pluginCacheAdapter: CacheAdapter = { 293 + async get(key: string): Promise<string | null> { 294 + return cache.get(key) 295 + }, 296 + async set(key: string, value: string, ttlSeconds?: number): Promise<void> { 297 + if (ttlSeconds !== undefined) { 298 + await cache.set(key, value, 'EX', ttlSeconds) 299 + } else { 300 + await cache.set(key, value) 301 + } 302 + }, 303 + async del(key: string): Promise<void> { 304 + await cache.del(key) 305 + }, 306 + } 307 + 242 308 // Profile sync (fetches AT Protocol profile from Bluesky public API at login) 243 - const profileSync = createProfileSyncService(db, app.log) 309 + const profileSync = createProfileSyncService(db, app.log, { 310 + loadedPlugins, 311 + enabledPlugins, 312 + oauthClient, 313 + cache: pluginCacheAdapter, 314 + communityDid: getCommunityDid(env), 315 + }) 244 316 app.decorate('profileSync', profileSync) 245 317 246 318 // PLC DID service + Setup service ··· 276 348 ozoneService = new OzoneService(db, cache, app.log, env.OZONE_LABELER_URL) 277 349 } 278 350 app.decorate('ozoneService', ozoneService) 351 + 352 + // Register plugin routes under /api/ext/<short-name>/ 353 + for (const [, loaded] of loadedPlugins) { 354 + if (!loaded.routesPath) continue 355 + 356 + const shortName = getPluginShortName(loaded.name) 357 + const routesFullPath = join(loaded.packagePath, loaded.routesPath) 358 + 359 + try { 360 + const routeModule = (await import(routesFullPath)) as Record<string, unknown> 361 + 362 + // Find the exported Fastify plugin function (convention: first function export) 363 + const routeFn = Object.values(routeModule).find((v) => typeof v === 'function') as 364 + | FastifyPluginCallback<{ ctx: PluginContext }> 365 + | undefined 366 + 367 + if (!routeFn) { 368 + app.log.warn({ plugin: loaded.name }, 'No route function export found') 369 + continue 370 + } 371 + 372 + // Query settings for this plugin from DB 373 + const pluginRows = (await db.execute( 374 + sql`SELECT id FROM plugins WHERE name = ${loaded.name}` 375 + )) as unknown as Array<{ id: string }> 376 + const pluginId = pluginRows[0]?.id 377 + 378 + const settingsObj: Record<string, unknown> = {} 379 + if (pluginId) { 380 + const settingsRows = (await db.execute( 381 + sql`SELECT key, value FROM plugin_settings WHERE plugin_id = ${pluginId}` 382 + )) as unknown as Array<{ key: string; value: unknown }> 383 + for (const s of settingsRows) { 384 + settingsObj[s.key] = s.value 385 + } 386 + } 387 + 388 + // Get permissions from manifest 389 + const manifestData = loaded.manifest as { permissions?: { backend?: string[] } } 390 + const permissions = manifestData.permissions?.backend ?? [] 391 + 392 + const ctx = createPluginContext({ 393 + pluginName: loaded.name, 394 + pluginVersion: loaded.version, 395 + permissions, 396 + settings: settingsObj, 397 + db, 398 + cache: pluginCacheAdapter, 399 + oauthClient, 400 + logger: app.log, 401 + communityDid: getCommunityDid(env), 402 + }) 403 + 404 + // Register in a scoped plugin with enabled-check preHandler 405 + await app.register( 406 + async function pluginRouteScope(scope) { 407 + scope.addHook('preHandler', async (_request, reply) => { 408 + if (!app.enabledPlugins.has(loaded.name)) { 409 + return reply.status(404).send({ error: 'Plugin not available' }) 410 + } 411 + }) 412 + await scope.register(routeFn, { ctx }) 413 + }, 414 + { prefix: `/api/ext/${shortName}` } 415 + ) 416 + 417 + app.log.info( 418 + { plugin: loaded.name, prefix: `/api/ext/${shortName}` }, 419 + 'Plugin routes registered' 420 + ) 421 + } catch (err: unknown) { 422 + app.log.error({ err, plugin: loaded.name }, 'Failed to register plugin routes') 423 + } 424 + } 279 425 280 426 // OpenAPI documentation (register before routes so schemas are collected) 281 427 await app.register(swagger, {
+81 -3
src/lib/plugins/context.ts
··· 1 + import { Agent } from '@atproto/api' 2 + 1 3 import type { Logger } from '../logger.js' 2 4 3 - import type { PluginContext, PluginSettings, ScopedCache, ScopedDatabase } from './types.js' 5 + import type { 6 + PluginContext, 7 + PluginSettings, 8 + ScopedAtProto, 9 + ScopedCache, 10 + ScopedDatabase, 11 + } from './types.js' 4 12 5 13 /** Adapter interface for the underlying cache (e.g. Valkey/ioredis). */ 6 14 export interface CacheAdapter { ··· 16 24 settings: Record<string, unknown> 17 25 db: unknown 18 26 cache: CacheAdapter | null 27 + oauthClient: unknown // NodeOAuthClient | null — typed as unknown to avoid coupling 19 28 logger: Logger 20 29 communityDid: string 21 30 } ··· 59 68 } 60 69 } 61 70 71 + const BSKY_PUBLIC_API = 'https://public.api.bsky.app' 72 + 73 + interface OAuthClientLike { 74 + restore(did: string): Promise<unknown> 75 + } 76 + 77 + function createScopedAtProto( 78 + oauthClient: OAuthClientLike, 79 + logger: Logger, 80 + pluginName: string 81 + ): ScopedAtProto { 82 + return { 83 + async getRecord(did: string, collection: string, rkey: string): Promise<unknown> { 84 + try { 85 + const agent = new Agent(new URL(BSKY_PUBLIC_API)) 86 + const response = await agent.com.atproto.repo.getRecord({ 87 + repo: did, 88 + collection, 89 + rkey, 90 + }) 91 + return response.data.value 92 + } catch (err: unknown) { 93 + logger.debug( 94 + { err, plugin: pluginName, did, collection, rkey }, 95 + 'ScopedAtProto getRecord failed' 96 + ) 97 + return null 98 + } 99 + }, 100 + 101 + async putRecord(did: string, collection: string, rkey: string, record: unknown): Promise<void> { 102 + const session = await oauthClient.restore(did) 103 + const agent = new Agent(session as ConstructorParameters<typeof Agent>[0]) 104 + await agent.com.atproto.repo.putRecord({ 105 + repo: did, 106 + collection, 107 + rkey, 108 + record: { $type: collection, ...(record as Record<string, unknown>) }, 109 + }) 110 + }, 111 + 112 + async deleteRecord(did: string, collection: string, rkey: string): Promise<void> { 113 + const session = await oauthClient.restore(did) 114 + const agent = new Agent(session as ConstructorParameters<typeof Agent>[0]) 115 + await agent.com.atproto.repo.deleteRecord({ 116 + repo: did, 117 + collection, 118 + rkey, 119 + }) 120 + }, 121 + } 122 + } 123 + 62 124 export function createPluginContext(options: PluginContextOptions): PluginContext { 63 - const { pluginName, pluginVersion, permissions, settings, db, cache, logger, communityDid } = 64 - options 125 + const { 126 + pluginName, 127 + pluginVersion, 128 + permissions, 129 + settings, 130 + db, 131 + cache, 132 + oauthClient, 133 + logger, 134 + communityDid, 135 + } = options 65 136 66 137 const hasCachePermission = 67 138 permissions.includes('cache:read') || permissions.includes('cache:write') 68 139 69 140 const scopedCache = hasCachePermission && cache ? createScopedCache(cache, pluginName) : undefined 70 141 142 + const hasPdsPermission = permissions.includes('pds:read') || permissions.includes('pds:write') 143 + const scopedAtProto = 144 + hasPdsPermission && oauthClient 145 + ? createScopedAtProto(oauthClient as OAuthClientLike, logger, pluginName) 146 + : undefined 147 + 71 148 return { 72 149 pluginName, 73 150 pluginVersion, ··· 76 153 settings: createPluginSettings(settings), 77 154 logger: logger.child({ plugin: pluginName }), 78 155 ...(scopedCache ? { cache: scopedCache } : {}), 156 + ...(scopedAtProto ? { atproto: scopedAtProto } : {}), 79 157 } satisfies PluginContext 80 158 }
+12 -1
src/lib/plugins/loader.ts
··· 166 166 discovered: { manifest: PluginManifest; packagePath: string }[], 167 167 db: DbExecutor, 168 168 logger: Logger 169 - ): Promise<void> { 169 + ): Promise<{ newPlugins: string[] }> { 170 + const existingRows = (await db.execute(sql`SELECT name FROM plugins`)) as Array<{ 171 + name: string 172 + }> 173 + const existingNames = new Set(existingRows.map((r) => r.name)) 174 + const newPlugins: string[] = [] 175 + 170 176 for (const { manifest } of discovered) { 177 + if (!existingNames.has(manifest.name)) { 178 + newPlugins.push(manifest.name) 179 + } 171 180 const manifestJson = JSON.stringify(manifest) 172 181 173 182 // Upsert plugin -- new plugins are inserted as disabled ··· 206 215 207 216 logger.info({ plugin: manifest.name, version: manifest.version }, 'Synced plugin to database') 208 217 } 218 + 219 + return { newPlugins } 209 220 }
+125
src/lib/plugins/runtime.ts
··· 1 + import { join } from 'node:path' 2 + 3 + import type { Logger } from '../logger.js' 4 + 5 + import type { PluginContext, PluginHooks, LoadedPlugin } from './types.js' 6 + import type { PluginManifest } from '../../validation/plugin-manifest.js' 7 + 8 + // --------------------------------------------------------------------------- 9 + // Hook reference parsing 10 + // --------------------------------------------------------------------------- 11 + 12 + export function resolveHookRef(ref: string): { modulePath: string; exportName: string } | null { 13 + const hashIndex = ref.indexOf('#') 14 + if (hashIndex <= 0) return null 15 + return { 16 + modulePath: ref.slice(0, hashIndex), 17 + exportName: ref.slice(hashIndex + 1), 18 + } 19 + } 20 + 21 + // --------------------------------------------------------------------------- 22 + // Plugin short name 23 + // --------------------------------------------------------------------------- 24 + 25 + export function getPluginShortName(name: string): string { 26 + if (name.startsWith('@barazo/plugin-')) return name.slice('@barazo/plugin-'.length) 27 + if (name.startsWith('barazo-plugin-')) return name.slice('barazo-plugin-'.length) 28 + return name 29 + } 30 + 31 + // --------------------------------------------------------------------------- 32 + // Hook execution 33 + // --------------------------------------------------------------------------- 34 + 35 + export async function executeHook( 36 + hookName: string, 37 + hookFn: (...args: unknown[]) => Promise<void> | void, 38 + ctx: PluginContext, 39 + logger: Logger, 40 + pluginName: string, 41 + ...extraArgs: unknown[] 42 + ): Promise<boolean> { 43 + try { 44 + await hookFn(ctx, ...extraArgs) 45 + logger.info({ plugin: pluginName, hook: hookName }, 'Plugin hook executed') 46 + return true 47 + } catch (err: unknown) { 48 + logger.error({ err, plugin: pluginName, hook: hookName }, 'Plugin hook failed') 49 + return false 50 + } 51 + } 52 + 53 + // --------------------------------------------------------------------------- 54 + // Load hooks from manifest 55 + // --------------------------------------------------------------------------- 56 + 57 + const HOOK_NAMES = ['onInstall', 'onUninstall', 'onEnable', 'onDisable', 'onProfileSync'] as const 58 + 59 + export async function loadPluginHooks( 60 + packagePath: string, 61 + manifest: PluginManifest, 62 + logger: Logger 63 + ): Promise<PluginHooks> { 64 + const hooks: PluginHooks = {} 65 + const hookEntries = manifest.hooks 66 + if (!hookEntries) return hooks 67 + 68 + for (const name of HOOK_NAMES) { 69 + const ref = hookEntries[name] 70 + if (!ref) continue 71 + 72 + const parsed = resolveHookRef(ref) 73 + if (!parsed) { 74 + logger.warn({ plugin: manifest.name, hook: name, ref }, 'Invalid hook reference, skipping') 75 + continue 76 + } 77 + 78 + try { 79 + const fullPath = join(packagePath, parsed.modulePath) 80 + const mod = (await import(fullPath)) as Record<string, unknown> 81 + const fn = mod[parsed.exportName] 82 + if (typeof fn === 'function') { 83 + // Each hook is assigned individually after type-checking the export. 84 + ;(hooks as Record<string, unknown>)[name] = fn 85 + } else { 86 + logger.warn( 87 + { plugin: manifest.name, hook: name, export: parsed.exportName }, 88 + 'Hook export is not a function' 89 + ) 90 + } 91 + } catch (err: unknown) { 92 + logger.error({ err, plugin: manifest.name, hook: name }, 'Failed to load hook module') 93 + } 94 + } 95 + 96 + return hooks 97 + } 98 + 99 + // --------------------------------------------------------------------------- 100 + // Build LoadedPlugin from discovery result 101 + // --------------------------------------------------------------------------- 102 + 103 + export async function buildLoadedPlugin( 104 + manifest: PluginManifest, 105 + packagePath: string, 106 + logger: Logger 107 + ): Promise<LoadedPlugin> { 108 + const hooks = await loadPluginHooks(packagePath, manifest, logger) 109 + 110 + return { 111 + name: manifest.name, 112 + displayName: manifest.displayName, 113 + version: manifest.version, 114 + description: manifest.description, 115 + source: manifest.source, 116 + category: manifest.category, 117 + manifest: manifest as unknown as Record<string, unknown>, 118 + packagePath, 119 + hooks, 120 + ...(manifest.backend?.routes !== undefined && { routesPath: manifest.backend.routes }), 121 + ...(manifest.backend?.migrations !== undefined && { 122 + migrationsPath: manifest.backend.migrations, 123 + }), 124 + } 125 + }
+2 -2
src/lib/plugins/types.ts
··· 9 9 /** Scoped AT Protocol operations (only available if plugin has pds:read or pds:write permission). */ 10 10 export interface ScopedAtProto { 11 11 getRecord(did: string, collection: string, rkey: string): Promise<unknown> 12 - putRecord(collection: string, rkey: string, record: unknown): Promise<void> 13 - deleteRecord(collection: string, rkey: string): Promise<void> 12 + putRecord(did: string, collection: string, rkey: string, record: unknown): Promise<void> 13 + deleteRecord(did: string, collection: string, rkey: string): Promise<void> 14 14 } 15 15 16 16 /** Scoped Valkey cache -- keys are auto-prefixed with plugin:<name>: */
+66 -1
src/routes/admin-plugins.ts
··· 5 5 import { promisify } from 'node:util' 6 6 import type { FastifyPluginCallback } from 'fastify' 7 7 import { notFound, badRequest, conflict, errorResponseSchema } from '../lib/api-errors.js' 8 - import { getRegistryIndex, searchRegistryPlugins, getFeaturedPlugins } from '../lib/plugins/registry.js' 8 + import { 9 + getRegistryIndex, 10 + searchRegistryPlugins, 11 + getFeaturedPlugins, 12 + } from '../lib/plugins/registry.js' 13 + import { executeHook, buildLoadedPlugin } from '../lib/plugins/runtime.js' 14 + import { createPluginContext } from '../lib/plugins/context.js' 9 15 import { updatePluginSettingsSchema, installPluginSchema } from '../validation/admin-plugins.js' 10 16 import { pluginManifestSchema } from '../validation/plugin-manifest.js' 11 17 import { plugins, pluginSettings } from '../db/schema/plugins.js' ··· 96 102 const { db } = app 97 103 const requireAdmin = app.requireAdmin 98 104 105 + function buildCtxForPlugin(pluginRow: typeof plugins.$inferSelect) { 106 + const manifest = pluginRow.manifestJson as { permissions?: { backend?: string[] } } 107 + return createPluginContext({ 108 + pluginName: pluginRow.name, 109 + pluginVersion: pluginRow.version, 110 + permissions: manifest.permissions?.backend ?? [], 111 + settings: {}, 112 + db: app.db, 113 + cache: null, 114 + oauthClient: null, 115 + logger: app.log, 116 + communityDid: '', 117 + }) 118 + } 119 + 99 120 // ------------------------------------------------------------------- 100 121 // GET /api/plugins (admin only) 101 122 // ------------------------------------------------------------------- ··· 254 275 throw notFound('Plugin not found after update') 255 276 } 256 277 278 + // Execute onEnable hook 279 + const loaded = app.loadedPlugins.get(plugin.name) 280 + if (loaded?.hooks?.onEnable) { 281 + const ctx = buildCtxForPlugin(updatedPlugin) 282 + // eslint-disable-next-line @typescript-eslint/unbound-method -- plugin hooks are standalone functions 283 + const hookFn = loaded.hooks.onEnable as (...args: unknown[]) => Promise<void> 284 + await executeHook('onEnable', hookFn, ctx, app.log, plugin.name) 285 + } 286 + app.enabledPlugins.add(plugin.name) 287 + 257 288 app.log.info( 258 289 { 259 290 event: 'plugin_enabled', ··· 337 368 throw notFound('Plugin not found after update') 338 369 } 339 370 371 + // Execute onDisable hook 372 + const loaded = app.loadedPlugins.get(plugin.name) 373 + if (loaded?.hooks?.onDisable) { 374 + const ctx = buildCtxForPlugin(updatedPlugin) 375 + // eslint-disable-next-line @typescript-eslint/unbound-method -- plugin hooks are standalone functions 376 + const hookFn = loaded.hooks.onDisable as (...args: unknown[]) => Promise<void> 377 + await executeHook('onDisable', hookFn, ctx, app.log, plugin.name) 378 + } 379 + app.enabledPlugins.delete(plugin.name) 380 + 340 381 app.log.info( 341 382 { 342 383 event: 'plugin_disabled', ··· 491 532 ) 492 533 } 493 534 535 + // Execute onUninstall hook before DB delete 536 + const loaded = app.loadedPlugins.get(plugin.name) 537 + if (loaded?.hooks?.onUninstall) { 538 + const ctx = buildCtxForPlugin(plugin) 539 + // eslint-disable-next-line @typescript-eslint/unbound-method -- plugin hooks are standalone functions 540 + const hookFn = loaded.hooks.onUninstall as (...args: unknown[]) => Promise<void> 541 + await executeHook('onUninstall', hookFn, ctx, app.log, plugin.name) 542 + } 543 + 494 544 await db.delete(plugins).where(eq(plugins.id, id)) 545 + 546 + app.enabledPlugins.delete(plugin.name) 547 + app.loadedPlugins.delete(plugin.name) 495 548 496 549 app.log.info( 497 550 { ··· 592 645 const newPlugin = inserted[0] 593 646 if (!newPlugin) { 594 647 throw badRequest('Failed to insert plugin') 648 + } 649 + 650 + // Load hooks for newly installed plugin and run onInstall 651 + const packageDirPath = packageDir.replace(/\/plugin\.json$/, '') 652 + const loadedPlugin = await buildLoadedPlugin(manifest, packageDirPath, app.log) 653 + app.loadedPlugins.set(manifest.name, loadedPlugin) 654 + 655 + if (loadedPlugin.hooks?.onInstall) { 656 + const ctx = buildCtxForPlugin(newPlugin) 657 + // eslint-disable-next-line @typescript-eslint/unbound-method -- plugin hooks are standalone functions 658 + const hookFn = loadedPlugin.hooks.onInstall as (...args: unknown[]) => Promise<void> 659 + await executeHook('onInstall', hookFn, ctx, app.log, manifest.name) 595 660 } 596 661 597 662 app.log.info(
+61 -2
src/services/profile-sync.ts
··· 4 4 import type { Database } from '../db/index.js' 5 5 import { users } from '../db/schema/users.js' 6 6 import { stripControlCharacters } from '../lib/sanitize-text.js' 7 + import type { LoadedPlugin } from '../lib/plugins/types.js' 8 + import { executeHook } from '../lib/plugins/runtime.js' 9 + import { createPluginContext, type CacheAdapter } from '../lib/plugins/context.js' 7 10 8 11 // --------------------------------------------------------------------------- 9 12 // Types ··· 70 73 createAgent(): AgentLike { 71 74 return new Agent(new URL(BSKY_PUBLIC_API)) 72 75 }, 76 + } 77 + 78 + // --------------------------------------------------------------------------- 79 + // Factory options 80 + // --------------------------------------------------------------------------- 81 + 82 + export interface ProfileSyncOptions { 83 + agentFactory?: AgentFactory 84 + loadedPlugins?: Map<string, LoadedPlugin> 85 + enabledPlugins?: Set<string> 86 + oauthClient?: unknown 87 + cache?: CacheAdapter | null 88 + communityDid?: string 73 89 } 74 90 75 91 // --------------------------------------------------------------------------- ··· 85 101 * 86 102 * @param db - Drizzle database instance 87 103 * @param logger - Pino logger 88 - * @param agentFactory - Optional factory for creating Agent instances (testing) 104 + * @param options - Optional configuration including agent factory and plugin refs 89 105 */ 90 106 export function createProfileSyncService( 91 107 db: Database, 92 108 logger: Logger, 93 - agentFactory: AgentFactory = defaultAgentFactory 109 + options: ProfileSyncOptions = {} 94 110 ): ProfileSyncService { 111 + const { 112 + agentFactory = defaultAgentFactory, 113 + loadedPlugins, 114 + enabledPlugins, 115 + oauthClient: pluginOauthClient, 116 + cache: pluginCache, 117 + communityDid: pluginCommunityDid, 118 + } = options 95 119 return { 96 120 async syncProfile(did: string): Promise<ProfileData> { 97 121 // 1. Fetch profile from Bluesky public API (no auth needed) ··· 140 164 .where(eq(users.did, did)) 141 165 } catch (err: unknown) { 142 166 logger.warn({ did, err }, 'profile DB update failed: could not persist profile data') 167 + } 168 + 169 + // Fire-and-forget plugin onProfileSync hooks 170 + if (loadedPlugins && enabledPlugins) { 171 + for (const [name, loaded] of loadedPlugins) { 172 + if (!enabledPlugins.has(name)) continue 173 + if (!loaded.hooks?.onProfileSync) continue 174 + 175 + try { 176 + const manifest = loaded.manifest as { permissions?: { backend?: string[] } } 177 + const ctx = createPluginContext({ 178 + pluginName: loaded.name, 179 + pluginVersion: loaded.version, 180 + permissions: manifest.permissions?.backend ?? [], 181 + settings: {}, 182 + db, 183 + cache: pluginCache ?? null, 184 + oauthClient: pluginOauthClient ?? null, 185 + logger, 186 + communityDid: pluginCommunityDid ?? '', 187 + }) 188 + // eslint-disable-next-line @typescript-eslint/unbound-method -- plugin hooks are standalone functions 189 + const hookFn = loaded.hooks.onProfileSync as (...args: unknown[]) => Promise<void> 190 + void executeHook('onProfileSync', hookFn, ctx, logger, name, did).catch( 191 + (err: unknown) => { 192 + logger.warn({ err, plugin: name, did }, 'Plugin onProfileSync failed') 193 + } 194 + ) 195 + } catch (err: unknown) { 196 + logger.warn( 197 + { err, plugin: name, did }, 198 + 'Failed to build plugin context for onProfileSync' 199 + ) 200 + } 201 + } 143 202 } 144 203 145 204 return profileData
+43
tests/unit/lib/plugins/context.test.ts
··· 31 31 settings: { maxLength: 200, prefix: '--' }, 32 32 db: {}, 33 33 cache: null, 34 + oauthClient: null, 34 35 logger: makeLogger(), 35 36 communityDid: 'did:plc:testcommunity123', 36 37 } ··· 130 131 expect(childFn).toHaveBeenCalledWith({ plugin: '@barazo/plugin-signatures' }) 131 132 }) 132 133 }) 134 + 135 + describe('ScopedAtProto', () => { 136 + it('provides atproto when pds:read permission is present', () => { 137 + const ctx = createPluginContext({ 138 + ...BASE_OPTIONS, 139 + permissions: ['pds:read'], 140 + oauthClient: {} as never, 141 + logger: makeLogger(), 142 + }) 143 + expect(ctx.atproto).toBeDefined() 144 + }) 145 + 146 + it('provides atproto when pds:write permission is present', () => { 147 + const ctx = createPluginContext({ 148 + ...BASE_OPTIONS, 149 + permissions: ['pds:write'], 150 + oauthClient: {} as never, 151 + logger: makeLogger(), 152 + }) 153 + expect(ctx.atproto).toBeDefined() 154 + }) 155 + 156 + it('does not provide atproto without pds permissions', () => { 157 + const ctx = createPluginContext({ 158 + ...BASE_OPTIONS, 159 + permissions: ['db:write:plugin_signatures'], 160 + oauthClient: null, 161 + logger: makeLogger(), 162 + }) 163 + expect(ctx.atproto).toBeUndefined() 164 + }) 165 + 166 + it('does not provide atproto when no oauthClient even with permissions', () => { 167 + const ctx = createPluginContext({ 168 + ...BASE_OPTIONS, 169 + permissions: ['pds:read', 'pds:write'], 170 + oauthClient: null, 171 + logger: makeLogger(), 172 + }) 173 + expect(ctx.atproto).toBeUndefined() 174 + }) 175 + })
+85
tests/unit/lib/plugins/runtime.test.ts
··· 1 + import { describe, expect, it, vi } from 'vitest' 2 + 3 + import { 4 + resolveHookRef, 5 + getPluginShortName, 6 + executeHook, 7 + } from '../../../../src/lib/plugins/runtime.js' 8 + 9 + function makeLogger() { 10 + return { 11 + info: vi.fn(), 12 + warn: vi.fn(), 13 + error: vi.fn(), 14 + debug: vi.fn(), 15 + trace: vi.fn(), 16 + fatal: vi.fn(), 17 + child: vi.fn().mockReturnThis(), 18 + level: 'info', 19 + } as never 20 + } 21 + 22 + describe('resolveHookRef', () => { 23 + it('parses module path and export name from hook reference', () => { 24 + const result = resolveHookRef('./backend/hooks.js#onInstall') 25 + expect(result).toEqual({ modulePath: './backend/hooks.js', exportName: 'onInstall' }) 26 + }) 27 + 28 + it('returns null for reference without hash separator', () => { 29 + expect(resolveHookRef('./backend/hooks.js')).toBeNull() 30 + }) 31 + 32 + it('returns null for empty string', () => { 33 + expect(resolveHookRef('')).toBeNull() 34 + }) 35 + 36 + it('handles hash at position 0 as invalid', () => { 37 + expect(resolveHookRef('#onInstall')).toBeNull() 38 + }) 39 + }) 40 + 41 + describe('getPluginShortName', () => { 42 + it('strips @barazo/plugin- prefix', () => { 43 + expect(getPluginShortName('@barazo/plugin-signatures')).toBe('signatures') 44 + }) 45 + 46 + it('strips barazo-plugin- prefix', () => { 47 + expect(getPluginShortName('barazo-plugin-editor')).toBe('editor') 48 + }) 49 + 50 + it('returns name unchanged if no known prefix', () => { 51 + expect(getPluginShortName('some-plugin')).toBe('some-plugin') 52 + }) 53 + }) 54 + 55 + describe('executeHook', () => { 56 + it('calls the hook function and returns true on success', async () => { 57 + const hook = vi.fn().mockResolvedValue(undefined) 58 + const logger = makeLogger() 59 + const result = await executeHook('onEnable', hook, {} as never, logger, '@barazo/plugin-test') 60 + expect(result).toBe(true) 61 + expect(hook).toHaveBeenCalledWith({}) 62 + }) 63 + 64 + it('returns false and logs error when hook throws', async () => { 65 + const hook = vi.fn().mockRejectedValue(new Error('boom')) 66 + const logger = makeLogger() 67 + const result = await executeHook('onEnable', hook, {} as never, logger, '@barazo/plugin-test') 68 + expect(result).toBe(false) 69 + expect(logger.error).toHaveBeenCalled() 70 + }) 71 + 72 + it('passes extra args to the hook for onProfileSync', async () => { 73 + const hook = vi.fn().mockResolvedValue(undefined) 74 + const logger = makeLogger() 75 + await executeHook( 76 + 'onProfileSync', 77 + hook, 78 + {} as never, 79 + logger, 80 + '@barazo/plugin-test', 81 + 'did:plc:user1' 82 + ) 83 + expect(hook).toHaveBeenCalledWith({}, 'did:plc:user1') 84 + }) 85 + })
+3
tests/unit/routes/admin-plugins.test.ts
··· 102 102 app.decorate('env', mockEnv) 103 103 app.decorate('requireAdmin', requireAdmin as never) 104 104 app.decorate('cache', {} as never) 105 + app.decorate('oauthClient', {} as never) 106 + app.decorate('loadedPlugins', new Map() as never) 107 + app.decorate('enabledPlugins', new Set() as never) 105 108 app.decorateRequest('user', undefined as RequestUser | undefined) 106 109 107 110 await app.register(adminPluginRoutes())
+15 -9
tests/unit/services/profile-sync.test.ts
··· 111 111 mockDb = createMockDb() 112 112 113 113 service = createProfileSyncService(mockDb, mockLogger, { 114 - createAgent: () => ({ 115 - getProfile: mockGetProfile, 116 - }), 114 + agentFactory: { 115 + createAgent: () => ({ 116 + getProfile: mockGetProfile, 117 + }), 118 + }, 117 119 }) 118 120 }) 119 121 ··· 251 253 }) 252 254 253 255 service = createProfileSyncService(mockDb, mockLogger, { 254 - createAgent: () => ({ 255 - getProfile: mockGetProfile, 256 - }), 256 + agentFactory: { 257 + createAgent: () => ({ 258 + getProfile: mockGetProfile, 259 + }), 260 + }, 257 261 }) 258 262 259 263 const result = await service.syncProfile(TEST_DID) ··· 287 291 }) 288 292 289 293 service = createProfileSyncService(mockDb, mockLogger, { 290 - createAgent: () => ({ 291 - getProfile: mockGetProfile, 292 - }), 294 + agentFactory: { 295 + createAgent: () => ({ 296 + getProfile: mockGetProfile, 297 + }), 298 + }, 293 299 }) 294 300 295 301 await service.syncProfile(TEST_DID)