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
Wiring HTTP du bridge service. 10 endpoints livres (cf docs/19 §6.1-6.5) :
- GET /api/v1/personnes (+ /:id, + /:id/dashboard)
- GET /api/v1/formations (+ /:id avec rollups blocs/modules)
- GET /api/v1/projets (+ /:id avec rollups taches)
- POST /api/v1/modules/:id/attribuer (RG-01 -> 422, role/heures invalides -> 400)
- POST /api/v1/interventions (validation role developpeur + heures > 0)
- PATCH /api/v1/attributions/:id/heures-realisees (409 si annule/realise)
Layers ajoutees :
- src/middleware/auth.ts : Bearer brg_*, scopes JSON-encoded BRIDGE_API_TOKENS, admin:* wildcard
- src/middleware/error-handler.ts : BridgeError -> JSON shape standard
- src/lib/container.ts : DI singleton (Baserow + Redis + 9 repos), setContainer testable
- src/lib/http.ts : parseListQuery + parseBody zod helper
- src/repos/baserow-repo.ts : BaseRepo<T> abstrait + 9 sous-classes (mapping Row<->Domain)
- src/routes/{personnes,formations,projets,modules,interventions,attributions}.ts
src/index.ts reecrit : buildApp() + initContainer + auth sur /api/v1/* + ready check Baserow+Redis.
Tests : 163/163 verts (12 suites domain + 8 nouvelles : auth, repos, 6 routes).
Coverage src global : 70.77% (cible 60%). Domain 97.86%, routes 96%, middleware 86%.
Choix : BaseRepo abstrait (pas mega-generic, Ockham) ; FakeRepos in-memory pour tests routes
(pas de testcontainers ici, c'est Bloc 7) ; mapping erreurs domain -> HTTP par message texte
(fragile, sera refactor en DomainError typees au Bloc 3.2).
Hors scope (a venir) :
- Bloc 5 : rate limiting Redis
- Bloc 7 : webhook handlers Baserow + sync bidirec + cache invalidation
- Bloc 3.2 : routes /docmost/*, /sync/*, /rapports/*
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
109 lines
3.4 KiB
TypeScript
109 lines
3.4 KiB
TypeScript
/**
|
|
* Auth middleware bridge — API tokens longue duree (`brg_*`) avec scopes.
|
|
*
|
|
* Tokens declares dans `BRIDGE_API_TOKENS` au format JSON :
|
|
* [{"token":"brg_xxx","name":"docmost-prod","scopes":["read:personnes","write:attributions"]}]
|
|
*
|
|
* JSON choisi plutot qu'un mini-DSL `name:scope1,scope2;...` : parse natif, pas d'ambiguite
|
|
* sur les separateurs si un nom contient virgule/point-virgule. Cf rapport Bloc 3.
|
|
*/
|
|
|
|
import type { MiddlewareHandler } from 'hono';
|
|
import { errors } from '../lib/errors.js';
|
|
|
|
export interface ApiTokenRecord {
|
|
token: string;
|
|
name: string;
|
|
scopes: string[];
|
|
}
|
|
|
|
export interface AuthContext {
|
|
tokenName: string;
|
|
scopes: ReadonlySet<string>;
|
|
}
|
|
|
|
/** Hono context variable map — augmente sur l'app pour acces type-safe. */
|
|
export type AuthVariables = {
|
|
auth: AuthContext;
|
|
};
|
|
|
|
/** Parse `BRIDGE_API_TOKENS` (JSON). Retourne map token → record. */
|
|
export function parseTokens(raw: string | undefined): Map<string, ApiTokenRecord> {
|
|
const map = new Map<string, ApiTokenRecord>();
|
|
if (!raw || raw.trim().length === 0) return map;
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = JSON.parse(raw);
|
|
} catch {
|
|
throw new Error('BRIDGE_API_TOKENS: JSON invalide');
|
|
}
|
|
if (!Array.isArray(parsed)) {
|
|
throw new Error('BRIDGE_API_TOKENS: doit etre un tableau JSON');
|
|
}
|
|
for (const entry of parsed) {
|
|
if (typeof entry !== 'object' || entry === null) {
|
|
throw new Error('BRIDGE_API_TOKENS: entrees doivent etre des objets');
|
|
}
|
|
const e = entry as Record<string, unknown>;
|
|
if (typeof e.token !== 'string' || typeof e.name !== 'string') {
|
|
throw new Error('BRIDGE_API_TOKENS: chaque entree requiert token + name');
|
|
}
|
|
if (!Array.isArray(e.scopes) || e.scopes.some((s: unknown) => typeof s !== 'string')) {
|
|
throw new Error('BRIDGE_API_TOKENS: scopes doit etre un tableau de strings');
|
|
}
|
|
map.set(e.token, { token: e.token, name: e.name, scopes: e.scopes as string[] });
|
|
}
|
|
return map;
|
|
}
|
|
|
|
/**
|
|
* Verifie si un set de scopes possedes couvre la demande.
|
|
* `admin:*` couvre tout. Match exact sinon.
|
|
*/
|
|
export function hasScope(owned: ReadonlySet<string>, required: string): boolean {
|
|
if (owned.has('admin:*')) return true;
|
|
return owned.has(required);
|
|
}
|
|
|
|
/**
|
|
* Factory middleware : exige un scope precis.
|
|
* Le middleware d'auth global doit avoir tourne avant pour peupler `c.var.auth`.
|
|
*/
|
|
export function requireScope(scope: string): MiddlewareHandler<{ Variables: AuthVariables }> {
|
|
return async (c, next) => {
|
|
const auth = c.get('auth');
|
|
if (!auth) {
|
|
throw errors.authRequired();
|
|
}
|
|
if (!hasScope(auth.scopes, scope)) {
|
|
throw errors.forbidden(scope);
|
|
}
|
|
await next();
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Middleware d'auth global. Lit `Authorization: Bearer brg_*`, peuple `c.var.auth`.
|
|
* Pas d'enforcement de scope ici — c'est le job de `requireScope`.
|
|
*/
|
|
export function authMiddleware(
|
|
tokens: ReadonlyMap<string, ApiTokenRecord>,
|
|
): MiddlewareHandler<{ Variables: AuthVariables }> {
|
|
return async (c, next) => {
|
|
const header = c.req.header('Authorization');
|
|
if (!header) {
|
|
throw errors.authRequired();
|
|
}
|
|
const match = header.match(/^Bearer\s+(.+)$/);
|
|
if (!match) {
|
|
throw errors.authInvalid();
|
|
}
|
|
const token = match[1].trim();
|
|
const record = tokens.get(token);
|
|
if (!record) {
|
|
throw errors.authInvalid();
|
|
}
|
|
c.set('auth', { tokenName: record.name, scopes: new Set(record.scopes) });
|
|
await next();
|
|
};
|
|
}
|