Sifa professional network API (Fastify, AT Protocol, Jetstream)
sifa.id/
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}