Wiki/bridge/src/index.ts
Corentin JOGUET 0cf6533885
Some checks are pending
CI / Lint bridge (Biome) (push) Waiting to run
CI / Type-check bridge (push) Blocked by required conditions
CI / Tests unit bridge (push) Blocked by required conditions
CI / Tests integration bridge (push) Blocked by required conditions
CI / Security scan (push) Waiting to run
CI / Docker build + healthcheck (push) Blocked by required conditions
feat(bridge): Bloc 5 rate limit + cache invalidation cote writes
2026-05-07 21:44:33 +02:00

128 lines
4.9 KiB
TypeScript

/**
* Bridge service entrypoint — Hono HTTP server.
*
* Boot sequence : loadConfig -> initContainer (Baserow + Redis + repos + token map)
* -> wire middleware globaux + routes /api/v1/* avec auth + serve.
*/
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 { attributionsRoutes } from './routes/attributions.js';
import { formationsRoutes } from './routes/formations.js';
import { interventionsRoutes } from './routes/interventions.js';
import { modulesRoutes } from './routes/modules.js';
import { personnesRoutes } from './routes/personnes.js';
import { projetsRoutes } from './routes/projets.js';
import { webhooksRoutes } from './routes/webhooks.js';
export async function buildApp(): Promise<Hono<{ Variables: AuthVariables }>> {
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,
groupsScopesMap: ctn.groupsScopesMap,
strictMapping: ctn.config.authStrictMapping,
cache: ctn.redis,
finder: ctn.repos.personnes,
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('/personnes', personnesRoutes);
v1.route('/formations', formationsRoutes);
v1.route('/projets', projetsRoutes);
v1.route('/modules', modulesRoutes);
v1.route('/interventions', interventionsRoutes);
v1.route('/attributions', attributionsRoutes);
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();
// Soit BASEROW_TABLE_IDS={"personne":609,...} (preferred — DB tokens n'ont pas
// acces a /api/database/tables/database/:id/), soit BASEROW_DATABASE_ID + un JWT
// user (Phase 3+). Cf doc 19 §5.
const tableIdsRaw = process.env.BASEROW_TABLE_IDS;
const databaseIdRaw = process.env.BASEROW_DATABASE_ID;
let initOpts: Parameters<typeof initContainer>[0];
if (tableIdsRaw) {
initOpts = { config, tableIds: JSON.parse(tableIdsRaw) };
} else {
const databaseId = databaseIdRaw ? Number.parseInt(databaseIdRaw, 10) : undefined;
if (!databaseId || Number.isNaN(databaseId)) {
throw new Error('BASEROW_TABLE_IDS ou BASEROW_DATABASE_ID requis');
}
initOpts = { config, databaseId };
}
await initContainer(initOpts);
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);
});
}