Barazo AppView backend barazo.forum
at main 555 lines 20 kB view raw
1import { join } from 'node:path' 2import Fastify from 'fastify' 3import helmet from '@fastify/helmet' 4import cors from '@fastify/cors' 5import cookie from '@fastify/cookie' 6import multipart from '@fastify/multipart' 7import rateLimit from '@fastify/rate-limit' 8import swagger from '@fastify/swagger' 9import scalarApiReference from '@scalar/fastify-api-reference' 10import * as Sentry from '@sentry/node' 11import type { FastifyError, FastifyPluginCallback } from 'fastify' 12import type { NodeOAuthClient } from '@atproto/oauth-client-node' 13import { sql } from 'drizzle-orm' 14import type { Env } from './config/env.js' 15import { getCommunityDid } from './config/env.js' 16import { createSingleResolver, registerCommunityResolver } from './middleware/community-resolver.js' 17import type { CommunityResolver } from './middleware/community-resolver.js' 18import { createDb, runMigrations } from './db/index.js' 19import { createCache } from './cache/index.js' 20import { FirehoseService } from './firehose/service.js' 21import { createOAuthClient } from './auth/oauth-client.js' 22import { createSessionService } from './auth/session.js' 23import type { SessionService } from './auth/session.js' 24import { createAuthMiddleware } from './auth/middleware.js' 25import type { AuthMiddleware, RequestUser } from './auth/middleware.js' 26import { healthRoutes } from './routes/health.js' 27import { oauthMetadataRoutes } from './routes/oauth-metadata.js' 28import { authRoutes } from './routes/auth.js' 29import { setupRoutes } from './routes/setup.js' 30import { topicRoutes } from './routes/topics.js' 31import { replyRoutes } from './routes/replies.js' 32import { categoryRoutes } from './routes/categories.js' 33import { pageRoutes } from './routes/pages.js' 34import { adminSettingsRoutes } from './routes/admin-settings.js' 35import { reactionRoutes } from './routes/reactions.js' 36import { voteRoutes } from './routes/votes.js' 37import { moderationRoutes } from './routes/moderation.js' 38import { modAnnotationRoutes } from './routes/mod-annotations.js' 39import { moderationQueueRoutes } from './routes/moderation-queue.js' 40import { searchRoutes } from './routes/search.js' 41import { notificationRoutes } from './routes/notifications.js' 42import { profileRoutes } from './routes/profiles.js' 43import { blockMuteRoutes } from './routes/block-mute.js' 44import { onboardingRoutes } from './routes/onboarding.js' 45import { globalFilterRoutes } from './routes/global-filters.js' 46import { communityProfileRoutes } from './routes/community-profiles.js' 47import { uploadRoutes } from './routes/uploads.js' 48import { adminSybilRoutes } from './routes/admin-sybil.js' 49import { adminDesignRoutes } from './routes/admin-design.js' 50import { adminPluginRoutes } from './routes/admin-plugins.js' 51import { communityRulesRoutes } from './routes/community-rules.js' 52import { discoverPlugins, syncPluginsToDb, validateAndFilterPlugins } from './lib/plugins/loader.js' 53import { buildLoadedPlugin, executeHook, getPluginShortName } from './lib/plugins/runtime.js' 54import { createPluginContext, type CacheAdapter } from './lib/plugins/context.js' 55import type { PluginContext } from './lib/plugins/types.js' 56import type { LoadedPlugin } from './lib/plugins/types.js' 57import { createRequireAdmin } from './auth/require-admin.js' 58import { createRequireOperator } from './auth/require-operator.js' 59import { OzoneService } from './services/ozone.js' 60import { createSetupService } from './setup/service.js' 61import type { SetupService } from './setup/service.js' 62import { createPlcDidService } from './services/plc-did.js' 63import { createHandleResolver } from './lib/handle-resolver.js' 64import type { HandleResolver } from './lib/handle-resolver.js' 65import { createDidDocumentVerifier } from './lib/did-document-verifier.js' 66import { createProfileSyncService } from './services/profile-sync.js' 67import type { ProfileSyncService } from './services/profile-sync.js' 68import { createLocalStorage } from './lib/storage.js' 69import type { StorageService } from './lib/storage.js' 70import type { Database } from './db/index.js' 71import type { Cache } from './cache/index.js' 72import { createInteractionGraphService } from './services/interaction-graph.js' 73import type { InteractionGraphService } from './services/interaction-graph.js' 74import { createTrustGraphService } from './services/trust-graph.js' 75import type { TrustGraphService } from './services/trust-graph.js' 76 77// Extend Fastify types with decorated properties 78declare module 'fastify' { 79 interface FastifyInstance { 80 db: Database 81 cache: Cache 82 env: Env 83 firehose: FirehoseService 84 oauthClient: NodeOAuthClient 85 sessionService: SessionService 86 authMiddleware: AuthMiddleware 87 setupService: SetupService 88 handleResolver: HandleResolver 89 requireAdmin: ReturnType<typeof createRequireAdmin> 90 requireOperator: ReturnType<typeof createRequireOperator> 91 ozoneService: OzoneService | null 92 profileSync: ProfileSyncService 93 storage: StorageService 94 interactionGraphService: InteractionGraphService 95 trustGraphService: TrustGraphService 96 loadedPlugins: Map<string, LoadedPlugin> 97 enabledPlugins: Set<string> 98 } 99} 100 101export async function buildApp(env: Env) { 102 // Initialize GlitchTip/Sentry if DSN provided 103 if (env.GLITCHTIP_DSN) { 104 Sentry.init({ 105 dsn: env.GLITCHTIP_DSN, 106 environment: 107 env.LOG_LEVEL === 'debug' || env.LOG_LEVEL === 'trace' ? 'development' : 'production', 108 }) 109 } 110 111 const app = Fastify({ 112 logger: { 113 level: env.LOG_LEVEL, 114 ...(process.env.NODE_ENV === 'development' && 115 (env.LOG_LEVEL === 'debug' || env.LOG_LEVEL === 'trace') 116 ? { transport: { target: 'pino-pretty' } } 117 : {}), 118 }, 119 trustProxy: true, 120 }) 121 122 // Database -- run migrations before creating the main connection pool 123 const migrationsFolder = new URL('../drizzle', import.meta.url).pathname 124 await runMigrations(env.DATABASE_URL, migrationsFolder) 125 app.log.info('Database migrations applied') 126 127 const { db, client: dbClient } = createDb(env.DATABASE_URL) 128 app.decorate('db', db) 129 app.decorate('env', env) 130 131 // Plugin discovery and DB sync 132 const nodeModulesPath = new URL('../node_modules', import.meta.url).pathname 133 const discovered = await discoverPlugins(nodeModulesPath, app.log) 134 const loadedPlugins = new Map<string, LoadedPlugin>() 135 const enabledPlugins = new Set<string>() 136 137 if (discovered.length > 0) { 138 const validManifests = validateAndFilterPlugins( 139 discovered.map((d) => d.manifest), 140 '0.1.0', 141 app.log 142 ) 143 app.log.info({ count: validManifests.length }, 'Plugins discovered') 144 145 const syncResult = await syncPluginsToDb(discovered, db, app.log) 146 147 // Build LoadedPlugin objects (resolve hooks, route paths) 148 for (const { manifest, packagePath } of discovered) { 149 const loaded = await buildLoadedPlugin(manifest, packagePath, app.log) 150 loadedPlugins.set(manifest.name, loaded) 151 } 152 153 // Run onInstall for newly discovered plugins 154 for (const newName of syncResult.newPlugins) { 155 const loaded = loadedPlugins.get(newName) 156 if (loaded?.hooks?.onInstall) { 157 const ctx = createPluginContext({ 158 pluginName: loaded.name, 159 pluginVersion: loaded.version, 160 permissions: [], 161 settings: {}, 162 db, 163 cache: null, 164 oauthClient: null, 165 logger: app.log, 166 communityDid: getCommunityDid(env), 167 }) 168 // eslint-disable-next-line @typescript-eslint/unbound-method -- plugin hooks are standalone functions 169 const hookFn = loaded.hooks.onInstall as (...args: unknown[]) => Promise<void> 170 await executeHook('onInstall', hookFn, ctx, app.log, loaded.name) 171 } 172 } 173 174 // Track enabled plugins 175 const enabledRows = (await db.execute( 176 sql`SELECT name FROM plugins WHERE enabled = true` 177 )) as unknown as Array<{ name: string }> 178 for (const row of enabledRows) { 179 enabledPlugins.add(row.name) 180 } 181 } else { 182 app.log.info('No plugins discovered') 183 } 184 185 app.decorate('loadedPlugins', loadedPlugins) 186 app.decorate('enabledPlugins', enabledPlugins) 187 188 // Cache 189 const cache = createCache(env.VALKEY_URL, app.log) 190 app.decorate('cache', cache) 191 192 // Firehose 193 const firehose = new FirehoseService(db, app.log, env) 194 app.decorate('firehose', firehose) 195 196 // Security headers -- strict CSP for all routes (no unsafe-inline). 197 // The /docs scope overrides this with a permissive CSP for Scalar UI. 198 await app.register(helmet, { 199 contentSecurityPolicy: { 200 directives: { 201 defaultSrc: ["'self'"], 202 scriptSrc: ["'self'"], 203 styleSrc: ["'self'"], 204 imgSrc: ["'self'", 'data:', 'https:'], 205 connectSrc: ["'self'"], 206 fontSrc: ["'self'"], 207 objectSrc: ["'none'"], 208 frameSrc: ["'none'"], 209 baseUri: ["'self'"], 210 formAction: ["'self'"], 211 frameAncestors: ["'none'"], 212 }, 213 }, 214 hsts: { 215 maxAge: 31536000, 216 includeSubDomains: true, 217 preload: true, 218 }, 219 }) 220 221 // CORS 222 await app.register(cors, { 223 origin: env.CORS_ORIGINS.split(',').map((o) => o.trim()), 224 credentials: true, 225 methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], 226 allowedHeaders: ['Content-Type', 'Authorization'], 227 }) 228 229 // Rate limiting 230 await app.register(rateLimit, { 231 max: env.RATE_LIMIT_READ_ANON, 232 timeWindow: '1 minute', 233 }) 234 235 // Cookies (must be registered before auth routes) 236 await app.register(cookie, { secret: env.SESSION_SECRET }) 237 238 // Multipart file uploads 239 await app.register(multipart, { 240 limits: { fileSize: env.UPLOAD_MAX_SIZE_BYTES }, 241 }) 242 243 // Community resolver (must run before auth middleware) 244 let resolver: CommunityResolver 245 if (env.COMMUNITY_MODE === 'multi') { 246 try { 247 const mod = await import('@barazo/multi-tenant') 248 // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment 249 resolver = mod.createMultiResolver(db, cache) 250 } catch { 251 throw new Error( 252 'COMMUNITY_MODE is "multi" but @barazo/multi-tenant package is not installed. ' + 253 'Install it or switch to COMMUNITY_MODE="single".' 254 ) 255 } 256 } else { 257 resolver = createSingleResolver(getCommunityDid(env)) 258 } 259 registerCommunityResolver(app, resolver, env.COMMUNITY_MODE) 260 261 // Set RLS session variable per request 262 app.addHook('onRequest', async (request) => { 263 if (request.communityDid) { 264 await db.execute( 265 sql`SELECT set_config('app.current_community_did', ${request.communityDid}, true)` 266 ) 267 } 268 }) 269 270 // OAuth client 271 const oauthClient = createOAuthClient(env, cache, app.log) 272 app.decorate('oauthClient', oauthClient) 273 274 // Session service 275 const sessionService = createSessionService(cache, app.log, { 276 sessionTtl: env.OAUTH_SESSION_TTL, 277 accessTokenTtl: env.OAUTH_ACCESS_TOKEN_TTL, 278 }) 279 app.decorate('sessionService', sessionService) 280 281 // DID document verifier (checks DID is still active via PLC directory, cached in Valkey) 282 const didVerifier = createDidDocumentVerifier(cache, app.log) 283 284 // Auth middleware (request decoration must happen before hooks can set the property) 285 app.decorateRequest('user', undefined as RequestUser | undefined) 286 const authMiddleware = createAuthMiddleware(sessionService, didVerifier, app.log) 287 app.decorate('authMiddleware', authMiddleware) 288 289 // Handle resolver (DID -> handle, with cache) 290 const handleResolver = createHandleResolver(cache, db, app.log) 291 app.decorate('handleResolver', handleResolver) 292 293 // Wrap Valkey/ioredis client as CacheAdapter for plugin contexts 294 const pluginCacheAdapter: CacheAdapter = { 295 async get(key: string): Promise<string | null> { 296 return cache.get(key) 297 }, 298 async set(key: string, value: string, ttlSeconds?: number): Promise<void> { 299 if (ttlSeconds !== undefined) { 300 await cache.set(key, value, 'EX', ttlSeconds) 301 } else { 302 await cache.set(key, value) 303 } 304 }, 305 async del(key: string): Promise<void> { 306 await cache.del(key) 307 }, 308 } 309 310 // Profile sync (fetches AT Protocol profile from Bluesky public API at login) 311 const profileSync = createProfileSyncService(db, app.log, { 312 loadedPlugins, 313 enabledPlugins, 314 oauthClient, 315 cache: pluginCacheAdapter, 316 communityDid: getCommunityDid(env), 317 }) 318 app.decorate('profileSync', profileSync) 319 320 // PLC DID service + Setup service 321 const plcDidService = createPlcDidService(app.log) 322 const setupService = createSetupService(db, app.log, env.AI_ENCRYPTION_KEY, plcDidService) 323 app.decorate('setupService', setupService) 324 325 // Admin middleware 326 const requireAdmin = createRequireAdmin(db, authMiddleware, app.log) 327 app.decorate('requireAdmin', requireAdmin) 328 329 // Operator middleware (multi mode only) 330 const requireOperator = createRequireOperator(env, authMiddleware, app.log) 331 app.decorate('requireOperator', requireOperator) 332 333 // Local file storage for uploads 334 const uploadBaseUrl = 335 env.UPLOAD_BASE_URL ?? env.CORS_ORIGINS.split(',')[0]?.trim() ?? 'http://localhost:3000' 336 const storage = createLocalStorage(env.UPLOAD_DIR, uploadBaseUrl, app.log) 337 app.decorate('storage', storage) 338 339 // Interaction graph service (records reply/reaction/co-participation edges) 340 const interactionGraphService = createInteractionGraphService(db, app.log) 341 app.decorate('interactionGraphService', interactionGraphService) 342 343 // Trust graph service (EigenTrust computation + score lookup) 344 const trustGraphService = createTrustGraphService(db, app.log) 345 app.decorate('trustGraphService', trustGraphService) 346 347 // Ozone labeler service (opt-in, only if URL is configured) 348 let ozoneService: OzoneService | null = null 349 if (env.OZONE_LABELER_URL) { 350 ozoneService = new OzoneService(db, cache, app.log, env.OZONE_LABELER_URL) 351 } 352 app.decorate('ozoneService', ozoneService) 353 354 // Register plugin routes under /api/ext/<short-name>/ 355 for (const [, loaded] of loadedPlugins) { 356 if (!loaded.routesPath) continue 357 358 const shortName = getPluginShortName(loaded.name) 359 const routesFullPath = join(loaded.packagePath, loaded.routesPath) 360 361 try { 362 const routeModule = (await import(routesFullPath)) as Record<string, unknown> 363 364 // Find the exported Fastify plugin function (convention: first function export) 365 const routeFn = Object.values(routeModule).find((v) => typeof v === 'function') as 366 | FastifyPluginCallback<{ ctx: PluginContext }> 367 | undefined 368 369 if (!routeFn) { 370 app.log.warn({ plugin: loaded.name }, 'No route function export found') 371 continue 372 } 373 374 // Query settings for this plugin from DB 375 const pluginRows = (await db.execute( 376 sql`SELECT id FROM plugins WHERE name = ${loaded.name}` 377 )) as unknown as Array<{ id: string }> 378 const pluginId = pluginRows[0]?.id 379 380 const settingsObj: Record<string, unknown> = {} 381 if (pluginId) { 382 const settingsRows = (await db.execute( 383 sql`SELECT key, value FROM plugin_settings WHERE plugin_id = ${pluginId}` 384 )) as unknown as Array<{ key: string; value: unknown }> 385 for (const s of settingsRows) { 386 settingsObj[s.key] = s.value 387 } 388 } 389 390 // Get permissions from manifest 391 const manifestData = loaded.manifest as { permissions?: { backend?: string[] } } 392 const permissions = manifestData.permissions?.backend ?? [] 393 394 const ctx = createPluginContext({ 395 pluginName: loaded.name, 396 pluginVersion: loaded.version, 397 permissions, 398 settings: settingsObj, 399 db, 400 cache: pluginCacheAdapter, 401 oauthClient, 402 logger: app.log, 403 communityDid: getCommunityDid(env), 404 }) 405 406 // Register in a scoped plugin with enabled-check preHandler 407 await app.register( 408 async function pluginRouteScope(scope) { 409 scope.addHook('preHandler', async (_request, reply) => { 410 if (!app.enabledPlugins.has(loaded.name)) { 411 return reply.status(404).send({ error: 'Plugin not available' }) 412 } 413 }) 414 await scope.register(routeFn, { ctx }) 415 }, 416 { prefix: `/api/ext/${shortName}` } 417 ) 418 419 app.log.info( 420 { plugin: loaded.name, prefix: `/api/ext/${shortName}` }, 421 'Plugin routes registered' 422 ) 423 } catch (err: unknown) { 424 app.log.error({ err, plugin: loaded.name }, 'Failed to register plugin routes') 425 } 426 } 427 428 // OpenAPI documentation (register before routes so schemas are collected) 429 await app.register(swagger, { 430 openapi: { 431 openapi: '3.1.0', 432 info: { 433 title: 'Barazo Forum API', 434 description: 'AT Protocol forum AppView -- portable identity, federated communities.', 435 version: '0.1.0', 436 }, 437 servers: [ 438 { 439 url: env.CORS_ORIGINS.split(',')[0]?.trim() ?? 'http://localhost:3000', 440 description: 'Primary server', 441 }, 442 ], 443 components: { 444 securitySchemes: { 445 bearerAuth: { 446 type: 'http', 447 scheme: 'bearer', 448 description: 'Access token from /api/auth/callback or /api/auth/refresh', 449 }, 450 }, 451 }, 452 }, 453 }) 454 455 // Scalar API docs UI requires inline scripts/styles and CDN assets. 456 // Register in a scoped plugin to override the strict global CSP. 457 await app.register(async function docsPlugin(scope) { 458 const docsCsp = [ 459 "default-src 'self'", 460 "script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net", 461 "style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net", 462 "img-src 'self' data: https:", 463 "connect-src 'self'", 464 "font-src 'self' https://cdn.jsdelivr.net", 465 "object-src 'none'", 466 "frame-src 'none'", 467 "base-uri 'self'", 468 "form-action 'self'", 469 "frame-ancestors 'none'", 470 ].join('; ') 471 472 scope.addHook('onRequest', async (_request, reply) => { 473 reply.header('content-security-policy', docsCsp) 474 }) 475 476 await scope.register(scalarApiReference, { 477 routePrefix: '/docs', 478 configuration: { 479 theme: 'kepler', 480 }, 481 }) 482 }) 483 484 // Routes 485 await app.register(healthRoutes) 486 await app.register(oauthMetadataRoutes(oauthClient)) 487 await app.register(authRoutes(oauthClient)) 488 await app.register(setupRoutes()) 489 await app.register(topicRoutes()) 490 await app.register(replyRoutes()) 491 await app.register(categoryRoutes()) 492 await app.register(pageRoutes()) 493 await app.register(adminSettingsRoutes()) 494 await app.register(reactionRoutes()) 495 await app.register(voteRoutes()) 496 await app.register(moderationRoutes()) 497 await app.register(modAnnotationRoutes()) 498 await app.register(moderationQueueRoutes()) 499 await app.register(searchRoutes()) 500 await app.register(notificationRoutes()) 501 await app.register(profileRoutes()) 502 await app.register(blockMuteRoutes()) 503 await app.register(onboardingRoutes()) 504 await app.register(globalFilterRoutes()) 505 await app.register(communityProfileRoutes()) 506 await app.register(uploadRoutes()) 507 await app.register(adminSybilRoutes()) 508 await app.register(adminDesignRoutes()) 509 await app.register(adminPluginRoutes()) 510 await app.register(communityRulesRoutes()) 511 512 // OpenAPI spec endpoint (after routes so all schemas are registered) 513 app.get('/api/openapi.json', { schema: { hide: true } }, async (_request, reply) => { 514 return reply.header('Content-Type', 'application/json').send(app.swagger()) 515 }) 516 517 // Start firehose and optional services when app is ready 518 app.addHook('onReady', async () => { 519 await firehose.start() 520 if (ozoneService) { 521 ozoneService.start() 522 } 523 }) 524 525 // Graceful shutdown: stop services before closing DB 526 app.addHook('onClose', async () => { 527 app.log.info('Shutting down...') 528 if (ozoneService) { 529 ozoneService.stop() 530 } 531 await firehose.stop() 532 await cache.quit() 533 await dbClient.end() 534 app.log.info('Connections closed') 535 }) 536 537 // GlitchTip error handler 538 app.setErrorHandler((error: FastifyError, request, reply) => { 539 if (env.GLITCHTIP_DSN) { 540 Sentry.captureException(error) 541 } 542 app.log.error({ err: error, requestId: request.id }, 'Unhandled error') 543 const statusCode = error.statusCode ?? 500 544 return reply.status(statusCode).send({ 545 error: 'Internal Server Error', 546 message: 547 env.LOG_LEVEL === 'debug' || env.LOG_LEVEL === 'trace' 548 ? error.message 549 : 'An unexpected error occurred', 550 statusCode, 551 }) 552 }) 553 554 return app 555}