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