/** * Bridge service entrypoint — Hono HTTP server. * * Boot sequence : loadConfig -> initContainer -> wire middleware globaux + * routes /api/v1/tables/* avec auth + serve. * * R1 — Plus de routes metier. Le bridge expose un proxy generique * `/api/v1/tables/*` style Notion. Le metier vit cote consumer. */ import { serve } from '@hono/node-server'; import { Hono } from 'hono'; import { logger as honoLogger } from 'hono/logger'; import { loadConfig } from './lib/config.js'; import { getContainer, initContainer } from './lib/container.js'; import { logger } from './lib/logger.js'; import { type AuthVariables, authMiddleware } from './middleware/auth.js'; import { errorHandler } from './middleware/error-handler.js'; import { defaultRateLimitKey, rateLimit } from './middleware/rate-limit.js'; import { tablesRoutes } from './routes/tables.js'; import { webhooksRoutes } from './routes/webhooks.js'; export async function buildApp(): Promise> { const app = new Hono<{ Variables: AuthVariables }>(); app.use('*', honoLogger()); app.onError(errorHandler); app.get('/api/health', (c) => c.json({ status: 'ok', service: 'bridge', version: '0.1.0' })); app.get('/api/ready', async (c) => { const { baserow, redis } = getContainer(); const [baserowOk, redisOk] = await Promise.all([baserow.healthCheck(), redis.healthCheck()]); const status = baserowOk && redisOk ? 'ok' : 'degraded'; const code = baserowOk && redisOk ? 200 : 503; return c.json( { status, dependencies: { baserow: baserowOk, redis: redisOk } }, code as 200 | 503, ); }); // Webhooks (HMAC auth) montes AVANT v1 (Bearer auth) — pas de double auth. app.route('/api/webhooks', webhooksRoutes); // Auth middleware applique sur tout /api/v1/* const ctn = getContainer(); const v1 = new Hono<{ Variables: AuthVariables }>(); v1.use( '*', authMiddleware({ tokens: ctn.tokens, oidc: ctn.oidc, docmostJwt: ctn.docmostJwt, groupsScopesMap: ctn.groupsScopesMap, logger: ctn.logger, }), ); // Rate limit global sur toute identite authentifiee. Place APRES authMiddleware // pour que `c.var.user` soit deja peuple — sinon la cle tombe sur IP. v1.use( '*', rateLimit(ctn.redis, { maxRequests: ctn.config.rateLimitGlobalMax, windowSeconds: ctn.config.rateLimitGlobalWindow, }), ); // Rate limit mutation : compteur dedie (suffixe `:mut`) qui ne s'applique // qu'aux verbes ecrivants. Plus strict que le global car bursts d'ecriture = // plus dangereux (charge Baserow + integrite donnees). const mutationLimiter = rateLimit(ctn.redis, { maxRequests: ctn.config.rateLimitMutationMax, windowSeconds: ctn.config.rateLimitMutationWindow, keyFrom: (c) => `${defaultRateLimitKey(c)}:mut`, }); v1.use('*', async (c, next) => { const method = c.req.method.toUpperCase(); if (method === 'POST' || method === 'PATCH' || method === 'PUT' || method === 'DELETE') { return mutationLimiter(c, next); } await next(); }); v1.route('/tables', tablesRoutes); app.route('/api/v1', v1); app.notFound((c) => c.json({ error: { code: 'NOT_FOUND', message: 'Route not found' } }, 404)); return app; } async function main() { const config = loadConfig(); await initContainer({ config }); const app = await buildApp(); serve({ fetch: app.fetch, port: config.port }, (info) => { logger.info({ port: info.port, env: config.nodeEnv }, 'Bridge service started'); }); } if (process.env.NODE_ENV !== 'test' && import.meta.url === `file://${process.argv[1]}`) { main().catch((err) => { logger.fatal({ err }, 'Bridge service failed to start'); process.exit(1); }); }