Sifa professional network API (Fastify, AT Protocol, Jetstream) sifa.id/
at main 178 lines 7.1 kB view raw
1import Fastify, { type FastifyError } from 'fastify'; 2import * as Sentry from '@sentry/node'; 3import './middleware/types.js'; 4import helmet from '@fastify/helmet'; 5import cors from '@fastify/cors'; 6import cookie from '@fastify/cookie'; 7import rateLimit from '@fastify/rate-limit'; 8import { existsSync } from 'node:fs'; 9import type { Env } from './config.js'; 10import { createDb } from './db/index.js'; 11import { runMigrations } from './db/migrate.js'; 12import { createValkey } from './cache/index.js'; 13import { createOAuthClient } from './oauth/client.js'; 14import { registerOAuthMetadata } from './oauth/metadata.js'; 15import { registerOAuthRoutes } from './oauth/routes.js'; 16import { registerProfileRoutes } from './routes/profile.js'; 17import { registerProfileWriteRoutes } from './routes/profile-write.js'; 18import { registerImportRoutes } from './routes/import.js'; 19import { registerFollowRoutes } from './routes/follow.js'; 20import { registerSearchRoutes } from './routes/search.js'; 21import { registerSkillsRoutes } from './routes/skills.js'; 22import { registerExternalAccountRoutes } from './routes/external-accounts.js'; 23import { registerSuggestionRoutes } from './routes/suggestions.js'; 24import { registerStatsRoutes } from './routes/stats.js'; 25import { registerAdminStatsRoutes } from './routes/admin-stats.js'; 26import { registerLocationRoutes } from './routes/location.js'; 27import { registerWellKnownRoutes } from './routes/well-known.js'; 28import { createJetstreamClient } from './jetstream/client.js'; 29import { createEventRouter } from './jetstream/handler.js'; 30import { createProfileIndexer } from './jetstream/indexers/profile.js'; 31import { createPositionIndexer } from './jetstream/indexers/position.js'; 32import { createEducationIndexer } from './jetstream/indexers/education.js'; 33import { createSkillIndexer } from './jetstream/indexers/skill.js'; 34import { createFollowIndexer } from './jetstream/indexers/follow.js'; 35import { createExternalAccountIndexer } from './jetstream/indexers/external-account.js'; 36import { createCertificationIndexer } from './jetstream/indexers/certification.js'; 37import { createProjectIndexer } from './jetstream/indexers/project.js'; 38import { createVolunteeringIndexer } from './jetstream/indexers/volunteering.js'; 39import { createPublicationIndexer } from './jetstream/indexers/publication.js'; 40import { createCourseIndexer } from './jetstream/indexers/course.js'; 41import { createHonorIndexer } from './jetstream/indexers/honor.js'; 42import { createLanguageIndexer } from './jetstream/indexers/language.js'; 43import { createCursorManager } from './jetstream/cursor.js'; 44 45export async function buildServer(config: Env) { 46 if (config.GLITCHTIP_DSN) { 47 Sentry.init({ 48 dsn: config.GLITCHTIP_DSN, 49 environment: config.NODE_ENV, 50 }); 51 } 52 53 const app = Fastify({ 54 logger: { 55 level: config.NODE_ENV === 'test' ? 'silent' : 'info', 56 }, 57 }); 58 59 app.decorateRequest('did', null); 60 app.decorateRequest('oauthSession', null); 61 62 const db = createDb(config.DATABASE_URL); 63 64 // Run pending migrations on startup 65 if (config.NODE_ENV !== 'test') { 66 await runMigrations(db); 67 app.log.info('Database migrations complete'); 68 } 69 70 const valkey = config.NODE_ENV !== 'test' ? createValkey(config.VALKEY_URL) : null; 71 if (valkey) await valkey.connect(); 72 73 if (config.GLITCHTIP_DSN) { 74 const dsnUrl = new URL(config.GLITCHTIP_DSN); 75 const key = dsnUrl.username; 76 const projectId = dsnUrl.pathname.replace('/', ''); 77 const reportUri = `${dsnUrl.protocol}//${dsnUrl.host}/api/${projectId}/security/?glitchtip_key=${key}`; 78 await app.register(helmet, { 79 contentSecurityPolicy: { 80 directives: { 81 'default-src': ["'self'"], 82 'report-uri': [reportUri], 83 }, 84 }, 85 }); 86 } else { 87 await app.register(helmet); 88 } 89 await app.register(cors, { origin: config.PUBLIC_URL, credentials: true }); 90 await app.register(cookie); 91 await app.register(rateLimit, { 92 max: 60, 93 timeWindow: '1 minute', 94 allowList: (req) => { 95 // Exempt Docker-internal traffic (service-to-service calls from sifa-web) 96 const ip = req.ip; 97 return ip === '127.0.0.1' || ip === '::1' || ip.startsWith('172.') || ip.startsWith('10.'); 98 }, 99 }); 100 101 app.setErrorHandler(async (error: FastifyError, _request, reply) => { 102 if (config.GLITCHTIP_DSN) { 103 Sentry.captureException(error); 104 } 105 app.log.error(error); 106 return reply.status(error.statusCode ?? 500).send({ 107 error: error.message, 108 }); 109 }); 110 111 app.get('/api/health', async () => ({ status: 'ok' })); 112 113 // Create OAuth client (required in non-test mode) 114 let oauthClient = null; 115 if (config.NODE_ENV !== 'test') { 116 if (!valkey) { 117 throw new Error('Valkey connection required for OAuth — check VALKEY_URL'); 118 } 119 if (!existsSync(config.OAUTH_JWKS_PATH)) { 120 const privateKeyPath = config.OAUTH_JWKS_PATH.replace('jwks', 'private-key'); 121 throw new Error( 122 `OAuth keys missing — expected ${config.OAUTH_JWKS_PATH} and ${privateKeyPath}. ` + 123 'Generate with: node -e "..." (see sifa-deploy README)', 124 ); 125 } 126 oauthClient = await createOAuthClient(config, db, valkey); 127 } 128 129 registerWellKnownRoutes(app, db, valkey, config.SIFA_DID); 130 registerOAuthMetadata(app, config); 131 registerOAuthRoutes(app, db, oauthClient); 132 registerProfileRoutes(app, db, valkey); 133 registerProfileWriteRoutes(app, db, oauthClient); 134 registerImportRoutes(app, db, oauthClient); 135 registerFollowRoutes(app, db, oauthClient); 136 registerSearchRoutes(app, db); 137 registerSkillsRoutes(app, db); 138 registerExternalAccountRoutes(app, db, oauthClient, valkey); 139 registerSuggestionRoutes(app, db, oauthClient, config.PUBLIC_URL); 140 registerStatsRoutes(app, db, valkey); 141 registerAdminStatsRoutes(app, db, valkey, oauthClient, config); 142 registerLocationRoutes(app, config.GEONAMES_USERNAME); 143 144 // Start Jetstream in non-test mode 145 if (config.NODE_ENV !== 'test') { 146 const cursorManager = createCursorManager(db); 147 148 const eventRouter = createEventRouter(db, { 149 profileIndexer: createProfileIndexer(db), 150 positionIndexer: createPositionIndexer(db), 151 educationIndexer: createEducationIndexer(db), 152 skillIndexer: createSkillIndexer(db), 153 certificationIndexer: createCertificationIndexer(db), 154 projectIndexer: createProjectIndexer(db), 155 volunteeringIndexer: createVolunteeringIndexer(db), 156 publicationIndexer: createPublicationIndexer(db), 157 courseIndexer: createCourseIndexer(db), 158 honorIndexer: createHonorIndexer(db), 159 languageIndexer: createLanguageIndexer(db), 160 followIndexer: createFollowIndexer(db), 161 externalAccountIndexer: createExternalAccountIndexer(db), 162 }); 163 164 const jetstream = createJetstreamClient({ 165 url: config.JETSTREAM_URL, 166 onEvent: async (event) => { 167 await eventRouter(event); 168 if (event.time_us) await cursorManager.save(BigInt(event.time_us)); 169 }, 170 getCursor: () => cursorManager.get(), 171 }); 172 173 await jetstream.connect(); 174 app.addHook('onClose', () => jetstream.disconnect()); 175 } 176 177 return app; 178}